From 116da92eb1db5369cc7f9f1741785933bfd3785f Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:55:51 -0700 Subject: [PATCH 001/115] feat(protocol): add SchemaAdapter protocol and SpanPanelAdapterMissingError --- src/span_panel_api/exceptions.py | 14 ++++++++++ src/span_panel_api/protocol.py | 36 ++++++++++++++++++++++++- tests/test_protocol_conformance.py | 43 ++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/span_panel_api/exceptions.py b/src/span_panel_api/exceptions.py index 24f1a56..16323a0 100644 --- a/src/span_panel_api/exceptions.py +++ b/src/span_panel_api/exceptions.py @@ -40,3 +40,17 @@ class SpanPanelStaleDataError(SpanPanelError): but data cannot be trusted right now (broker disconnected, or the Homie device has declared $state=disconnected/lost). """ + + +class SpanPanelAdapterMissingError(SpanPanelError): + """No installed adapter covers the schema this panel publishes.""" + + def __init__(self, needed: str, reason: str, available: list[str]) -> None: + self.needed = needed + self.reason = reason + self.available = available + super().__init__( + f"Panel requires adapter {needed!r} (reason: {reason}); " + f"installed adapters: {sorted(available)}. " + "Update the integration or install the missing adapter package." + ) diff --git a/src/span_panel_api/protocol.py b/src/span_panel_api/protocol.py index 11f7e97..68fd44c 100644 --- a/src/span_panel_api/protocol.py +++ b/src/span_panel_api/protocol.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable if TYPE_CHECKING: - from .models import FieldMetadata, SpanPanelSnapshot + from .models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot class PanelCapability(Flag): @@ -77,3 +77,37 @@ def register_snapshot_callback( async def start_streaming(self) -> None: ... async def stop_streaming(self) -> None: ... + + +@runtime_checkable +class SchemaAdapter(Protocol): + """Parser for a single data-model-major schema. + + Frozen within a major version of this package. Every method here is called + by SpanMqttClient; nothing else in the bootstrap knows the wire format. + """ + + schema_major: str + SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] + + def topics_to_subscribe(self) -> list[str]: ... + + def handle_message(self, topic: str, payload: str) -> None: ... + + def is_ready(self) -> bool: ... + + def build_snapshot(self) -> SpanPanelSnapshot: ... + + def build_field_metadata(self, schema_types: HomieSchemaTypes) -> dict[str, FieldMetadata]: ... + + def circuit_nodes_missing_names(self) -> list[str]: ... + + def find_node_by_type(self, type_str: str) -> str | None: ... + + def set_circuit_relay_topic(self, circuit_id: str) -> str: ... + + def set_circuit_priority_topic(self, circuit_id: str) -> str: ... + + def set_dominant_power_source_topic(self) -> str | None: ... + + def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]: ... diff --git a/tests/test_protocol_conformance.py b/tests/test_protocol_conformance.py index b3b8b52..dc7eb0e 100644 --- a/tests/test_protocol_conformance.py +++ b/tests/test_protocol_conformance.py @@ -46,3 +46,46 @@ def test_satisfies_panel_control_protocol(self) -> None: def test_satisfies_streaming_protocol(self) -> None: if not issubclass(SpanMqttClient, StreamingCapableProtocol): raise TypeError("SpanMqttClient does not satisfy StreamingCapableProtocol") + + +def test_schema_adapter_declares_its_methods() -> None: + """The protocol must name every method SpanMqttClient calls on its parser.""" + from span_panel_api.protocol import SchemaAdapter + + for name in ( + "topics_to_subscribe", + "handle_message", + "is_ready", + "build_snapshot", + "build_field_metadata", + "circuit_nodes_missing_names", + "find_node_by_type", + "set_circuit_relay_topic", + "set_circuit_priority_topic", + "set_dominant_power_source_topic", + "register_property_callback", + ): + assert hasattr(SchemaAdapter, name), f"SchemaAdapter is missing method {name}" + + +def test_schema_adapter_declares_its_class_attributes() -> None: + """`schema_major` and `SUPPORTS_DATA_MODEL_VERSIONS` are annotation-only members. + + A bare annotation on a Protocol creates no class attribute, so `hasattr` is + False for them even when correctly declared — they must be checked through + `__annotations__` instead. + """ + from span_panel_api.protocol import SchemaAdapter + + for name in ("schema_major", "SUPPORTS_DATA_MODEL_VERSIONS"): + assert name in SchemaAdapter.__annotations__, f"SchemaAdapter is missing attribute {name}" + + +def test_adapter_missing_error_reports_what_is_installed() -> None: + from span_panel_api.exceptions import SpanPanelAdapterMissingError + + err = SpanPanelAdapterMissingError(needed="schema_1", reason="data-model-version='1.0'", available=["schema_0"]) + assert err.needed == "schema_1" + assert err.available == ["schema_0"] + assert "schema_1" in str(err) + assert "schema_0" in str(err) From 7854fb4866453f4a3295d51880fa4d7b082a1855 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:10:11 -0700 Subject: [PATCH 002/115] refactor: relocate flat-schema parsing into _impl/schema_0 Move homie.py, accumulator.py, and field_metadata.py from mqtt/ into a new _impl/schema_0/ package, and split the flat-schema Homie constants (topic formats, TYPE_*, lugs direction, circuit id helpers) out of mqtt/const.py into _impl/schema_0/const.py. mqtt/const.py keeps only transport-level constants (HOMIE_STATE_*, MQTT_* connection settings). Pure relocation: only import statements changed in the moved files. Public API is unchanged (HomiePropertyAccumulator re-exported from the new location; __all__ verified identical before/after). --- src/span_panel_api/_impl/__init__.py | 1 + src/span_panel_api/_impl/schema_0/__init__.py | 1 + .../{mqtt => _impl/schema_0}/accumulator.py | 3 +- src/span_panel_api/_impl/schema_0/const.py | 42 +++++++++++++++++++ .../homie.py => _impl/schema_0/consumer.py} | 12 ++++-- .../schema_0}/field_metadata.py | 4 +- src/span_panel_api/mqtt/__init__.py | 5 ++- src/span_panel_api/mqtt/client.py | 10 +++-- src/span_panel_api/mqtt/const.py | 41 ------------------ tests/conftest.py | 2 +- tests/test_accumulator.py | 4 +- tests/test_auth_and_homie_helpers.py | 4 +- tests/test_field_metadata.py | 2 +- tests/test_mqtt_client_connection.py | 4 +- tests/test_mqtt_homie.py | 17 ++++---- 15 files changed, 81 insertions(+), 71 deletions(-) create mode 100644 src/span_panel_api/_impl/__init__.py create mode 100644 src/span_panel_api/_impl/schema_0/__init__.py rename src/span_panel_api/{mqtt => _impl/schema_0}/accumulator.py (98%) create mode 100644 src/span_panel_api/_impl/schema_0/const.py rename src/span_panel_api/{mqtt/homie.py => _impl/schema_0/consumer.py} (98%) rename src/span_panel_api/{mqtt => _impl/schema_0}/field_metadata.py (98%) diff --git a/src/span_panel_api/_impl/__init__.py b/src/span_panel_api/_impl/__init__.py new file mode 100644 index 0000000..b7981bd --- /dev/null +++ b/src/span_panel_api/_impl/__init__.py @@ -0,0 +1 @@ +"""Internal implementation packages. Not public API.""" diff --git a/src/span_panel_api/_impl/schema_0/__init__.py b/src/span_panel_api/_impl/schema_0/__init__.py new file mode 100644 index 0000000..63c37b9 --- /dev/null +++ b/src/span_panel_api/_impl/schema_0/__init__.py @@ -0,0 +1 @@ +"""Flat-schema (Homie v5) parsing implementation. Not public API.""" diff --git a/src/span_panel_api/mqtt/accumulator.py b/src/span_panel_api/_impl/schema_0/accumulator.py similarity index 98% rename from src/span_panel_api/mqtt/accumulator.py rename to src/span_panel_api/_impl/schema_0/accumulator.py index a102ac8..8b82e7f 100644 --- a/src/span_panel_api/mqtt/accumulator.py +++ b/src/span_panel_api/_impl/schema_0/accumulator.py @@ -13,7 +13,8 @@ import logging import time -from .const import HOMIE_STATE_DISCONNECTED, HOMIE_STATE_LOST, HOMIE_STATE_READY, TOPIC_PREFIX +from span_panel_api._impl.schema_0.const import TOPIC_PREFIX +from span_panel_api.mqtt.const import HOMIE_STATE_DISCONNECTED, HOMIE_STATE_LOST, HOMIE_STATE_READY _LOGGER = logging.getLogger(__name__) diff --git a/src/span_panel_api/_impl/schema_0/const.py b/src/span_panel_api/_impl/schema_0/const.py new file mode 100644 index 0000000..74b17e5 --- /dev/null +++ b/src/span_panel_api/_impl/schema_0/const.py @@ -0,0 +1,42 @@ +"""Constants for the flat-schema (Homie v5) parsing implementation.""" + +# Homie v5 topic structure +HOMIE_VERSION = 5 +HOMIE_DOMAIN = "ebus" +TOPIC_PREFIX = f"{HOMIE_DOMAIN}/{HOMIE_VERSION}" + +# Topic patterns (serial_number substituted at runtime) +DEVICE_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}" +STATE_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/$state" +DESCRIPTION_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/$description" +PROPERTY_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/{{node}}/{{prop}}" +PROPERTY_SET_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/{{node}}/{{prop}}/set" +WILDCARD_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/#" + +# Homie type strings from schema +TYPE_CORE = "energy.ebus.device.distribution-enclosure.core" +TYPE_LUGS = "energy.ebus.device.lugs" +TYPE_LUGS_UPSTREAM = "energy.ebus.device.lugs.upstream" +TYPE_LUGS_DOWNSTREAM = "energy.ebus.device.lugs.downstream" +TYPE_CIRCUIT = "energy.ebus.device.circuit" +TYPE_BESS = "energy.ebus.device.bess" +TYPE_PV = "energy.ebus.device.pv" +TYPE_EVSE = "energy.ebus.device.evse" +TYPE_PCS = "energy.ebus.device.pcs" +TYPE_POWER_FLOWS = "energy.ebus.device.power-flows" + +# Lugs direction values +LUGS_UPSTREAM = "UPSTREAM" +LUGS_DOWNSTREAM = "DOWNSTREAM" + + +def normalize_circuit_id(node_id: str) -> str: + """Strip dashes from Homie UUID for entity stability.""" + return node_id.replace("-", "") + + +def denormalize_circuit_id(circuit_id: str) -> str: + """Restore dashes to a 32-char dashless UUID (8-4-4-4-12 format).""" + if len(circuit_id) == 32 and "-" not in circuit_id: + return f"{circuit_id[:8]}-{circuit_id[8:12]}-{circuit_id[12:16]}-{circuit_id[16:20]}-{circuit_id[20:]}" + return circuit_id diff --git a/src/span_panel_api/mqtt/homie.py b/src/span_panel_api/_impl/schema_0/consumer.py similarity index 98% rename from src/span_panel_api/mqtt/homie.py rename to src/span_panel_api/_impl/schema_0/consumer.py index 88767e8..ba7a29c 100644 --- a/src/span_panel_api/mqtt/homie.py +++ b/src/span_panel_api/_impl/schema_0/consumer.py @@ -12,9 +12,8 @@ import time from typing import ClassVar -from ..models import SpanBatterySnapshot, SpanCircuitSnapshot, SpanEvseSnapshot, SpanPanelSnapshot, SpanPVSnapshot -from .accumulator import HomiePropertyAccumulator -from .const import ( +from span_panel_api._impl.schema_0.accumulator import HomiePropertyAccumulator +from span_panel_api._impl.schema_0.const import ( LUGS_DOWNSTREAM, LUGS_UPSTREAM, TYPE_BESS, @@ -28,6 +27,13 @@ TYPE_PV, normalize_circuit_id, ) +from span_panel_api.models import ( + SpanBatterySnapshot, + SpanCircuitSnapshot, + SpanEvseSnapshot, + SpanPanelSnapshot, + SpanPVSnapshot, +) _LOGGER = logging.getLogger(__name__) diff --git a/src/span_panel_api/mqtt/field_metadata.py b/src/span_panel_api/_impl/schema_0/field_metadata.py similarity index 98% rename from src/span_panel_api/mqtt/field_metadata.py rename to src/span_panel_api/_impl/schema_0/field_metadata.py index a888506..500389b 100644 --- a/src/span_panel_api/mqtt/field_metadata.py +++ b/src/span_panel_api/_impl/schema_0/field_metadata.py @@ -17,8 +17,7 @@ import logging -from ..models import FieldMetadata, HomieSchemaTypes -from .const import ( +from span_panel_api._impl.schema_0.const import ( TYPE_BESS, TYPE_CIRCUIT, TYPE_CORE, @@ -29,6 +28,7 @@ TYPE_POWER_FLOWS, TYPE_PV, ) +from span_panel_api.models import FieldMetadata, HomieSchemaTypes _LOGGER = logging.getLogger(__name__) diff --git a/src/span_panel_api/mqtt/__init__.py b/src/span_panel_api/mqtt/__init__.py index 6eaebeb..8be5e51 100644 --- a/src/span_panel_api/mqtt/__init__.py +++ b/src/span_panel_api/mqtt/__init__.py @@ -1,10 +1,11 @@ """SPAN Panel MQTT/Homie transport.""" -from .accumulator import HomieLifecycle, HomiePropertyAccumulator +from span_panel_api._impl.schema_0.accumulator import HomieLifecycle, HomiePropertyAccumulator +from span_panel_api._impl.schema_0.consumer import HomieDeviceConsumer + from .async_client import AsyncMQTTClient from .client import SpanMqttClient from .connection import AsyncMqttBridge -from .homie import HomieDeviceConsumer from .models import MqttClientConfig __all__ = [ diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index af52623..394f5b1 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -13,15 +13,17 @@ import logging import time +from span_panel_api._impl.schema_0.accumulator import HomiePropertyAccumulator +from span_panel_api._impl.schema_0.const import PROPERTY_SET_TOPIC_FMT, TYPE_CORE, WILDCARD_TOPIC_FMT +from span_panel_api._impl.schema_0.consumer import HomieDeviceConsumer +from span_panel_api._impl.schema_0.field_metadata import build_field_metadata, log_schema_drift + from ..auth import get_homie_schema from ..exceptions import SpanPanelConnectionError, SpanPanelServerError, SpanPanelStaleDataError from ..models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot from ..protocol import PanelCapability -from .accumulator import HomiePropertyAccumulator from .connection import AsyncMqttBridge -from .const import MQTT_READY_TIMEOUT_S, PROPERTY_SET_TOPIC_FMT, TYPE_CORE, WILDCARD_TOPIC_FMT -from .field_metadata import build_field_metadata, log_schema_drift -from .homie import HomieDeviceConsumer +from .const import MQTT_READY_TIMEOUT_S from .models import MqttClientConfig _LOGGER = logging.getLogger(__name__) diff --git a/src/span_panel_api/mqtt/const.py b/src/span_panel_api/mqtt/const.py index b5bc893..ac49f40 100644 --- a/src/span_panel_api/mqtt/const.py +++ b/src/span_panel_api/mqtt/const.py @@ -1,18 +1,5 @@ """Constants for SPAN Panel MQTT/Homie transport.""" -# Homie v5 topic structure -HOMIE_VERSION = 5 -HOMIE_DOMAIN = "ebus" -TOPIC_PREFIX = f"{HOMIE_DOMAIN}/{HOMIE_VERSION}" - -# Topic patterns (serial_number substituted at runtime) -DEVICE_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}" -STATE_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/$state" -DESCRIPTION_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/$description" -PROPERTY_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/{{node}}/{{prop}}" -PROPERTY_SET_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/{{node}}/{{prop}}/set" -WILDCARD_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/#" - # Homie device states HOMIE_STATE_INIT = "init" HOMIE_STATE_READY = "ready" @@ -21,18 +8,6 @@ HOMIE_STATE_LOST = "lost" HOMIE_STATE_ALERT = "alert" -# Homie type strings from schema -TYPE_CORE = "energy.ebus.device.distribution-enclosure.core" -TYPE_LUGS = "energy.ebus.device.lugs" -TYPE_LUGS_UPSTREAM = "energy.ebus.device.lugs.upstream" -TYPE_LUGS_DOWNSTREAM = "energy.ebus.device.lugs.downstream" -TYPE_CIRCUIT = "energy.ebus.device.circuit" -TYPE_BESS = "energy.ebus.device.bess" -TYPE_PV = "energy.ebus.device.pv" -TYPE_EVSE = "energy.ebus.device.evse" -TYPE_PCS = "energy.ebus.device.pcs" -TYPE_POWER_FLOWS = "energy.ebus.device.power-flows" - # MQTT connection defaults MQTT_DEFAULT_MQTTS_PORT = 8883 MQTT_DEFAULT_WS_PORT = 9001 @@ -53,19 +28,3 @@ # going through HA's config_entry teardown. Resets after every rebuild attempt so the cadence holds # throughout extended outages. MQTT_FULL_REBUILD_AFTER_FAILURES = 3 - -# Lugs direction values -LUGS_UPSTREAM = "UPSTREAM" -LUGS_DOWNSTREAM = "DOWNSTREAM" - - -def normalize_circuit_id(node_id: str) -> str: - """Strip dashes from Homie UUID for entity stability.""" - return node_id.replace("-", "") - - -def denormalize_circuit_id(circuit_id: str) -> str: - """Restore dashes to a 32-char dashless UUID (8-4-4-4-12 format).""" - if len(circuit_id) == 32 and "-" not in circuit_id: - return f"{circuit_id[:8]}-{circuit_id[8:12]}-{circuit_id[12:16]}-{circuit_id[16:20]}-{circuit_id[20:]}" - return circuit_id diff --git a/tests/conftest.py b/tests/conftest.py index 8a80b68..96e1458 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,7 @@ import span_panel_api._http as _http_mod from span_panel_api.models import V2HomieSchema -from span_panel_api.mqtt.const import TOPIC_PREFIX, TYPE_CORE +from span_panel_api._impl.schema_0.const import TOPIC_PREFIX, TYPE_CORE @pytest.fixture(autouse=True) diff --git a/tests/test_accumulator.py b/tests/test_accumulator.py index bd038fe..4e6d216 100644 --- a/tests/test_accumulator.py +++ b/tests/test_accumulator.py @@ -21,8 +21,8 @@ import pytest -from span_panel_api.mqtt.accumulator import HomieLifecycle, HomiePropertyAccumulator -from span_panel_api.mqtt.const import TOPIC_PREFIX +from span_panel_api._impl.schema_0.accumulator import HomieLifecycle, HomiePropertyAccumulator +from span_panel_api._impl.schema_0.const import TOPIC_PREFIX SERIAL = "nj-2316-XXXX" PREFIX = f"{TOPIC_PREFIX}/{SERIAL}" diff --git a/tests/test_auth_and_homie_helpers.py b/tests/test_auth_and_homie_helpers.py index a65fb67..cf73a1a 100644 --- a/tests/test_auth_and_homie_helpers.py +++ b/tests/test_auth_and_homie_helpers.py @@ -8,10 +8,10 @@ import httpx import pytest +from span_panel_api._impl.schema_0.accumulator import HomiePropertyAccumulator +from span_panel_api._impl.schema_0.consumer import HomieDeviceConsumer, _parse_int from span_panel_api.auth import _int, download_ca_cert, get_homie_schema from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelTimeoutError -from span_panel_api.mqtt.accumulator import HomiePropertyAccumulator -from span_panel_api.mqtt.homie import HomieDeviceConsumer, _parse_int # --------------------------------------------------------------------------- diff --git a/tests/test_field_metadata.py b/tests/test_field_metadata.py index 72dcb40..ac19e2a 100644 --- a/tests/test_field_metadata.py +++ b/tests/test_field_metadata.py @@ -5,7 +5,7 @@ import logging from span_panel_api.models import FieldMetadata -from span_panel_api.mqtt.field_metadata import build_field_metadata, log_schema_drift +from span_panel_api._impl.schema_0.field_metadata import build_field_metadata, log_schema_drift def _make_schema_types() -> dict[str, dict[str, object]]: diff --git a/tests/test_mqtt_client_connection.py b/tests/test_mqtt_client_connection.py index 581b5a6..cfa2527 100644 --- a/tests/test_mqtt_client_connection.py +++ b/tests/test_mqtt_client_connection.py @@ -8,10 +8,10 @@ from span_panel_api.exceptions import SpanPanelError, SpanPanelStaleDataError from span_panel_api.models import SpanPanelSnapshot +from span_panel_api._impl.schema_0.const import WILDCARD_TOPIC_FMT +from span_panel_api._impl.schema_0.consumer import HomieDeviceConsumer from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.connection import AsyncMqttBridge -from span_panel_api.mqtt.const import WILDCARD_TOPIC_FMT -from span_panel_api.mqtt.homie import HomieDeviceConsumer from span_panel_api.mqtt.models import MqttClientConfig diff --git a/tests/test_mqtt_homie.py b/tests/test_mqtt_homie.py index fa92a0e..fb0389a 100644 --- a/tests/test_mqtt_homie.py +++ b/tests/test_mqtt_homie.py @@ -22,11 +22,8 @@ import pytest -from span_panel_api.mqtt.const import ( - HOMIE_STATE_READY, - MQTT_DEFAULT_MQTTS_PORT, - MQTT_DEFAULT_WS_PORT, - MQTT_DEFAULT_WSS_PORT, +from span_panel_api._impl.schema_0.accumulator import HomiePropertyAccumulator +from span_panel_api._impl.schema_0.const import ( TOPIC_PREFIX, TYPE_BESS, TYPE_CIRCUIT, @@ -38,9 +35,9 @@ TYPE_POWER_FLOWS, TYPE_PV, ) -from span_panel_api.mqtt.accumulator import HomiePropertyAccumulator +from span_panel_api._impl.schema_0.consumer import HomieDeviceConsumer +from span_panel_api.mqtt.const import HOMIE_STATE_READY, MQTT_DEFAULT_MQTTS_PORT, MQTT_DEFAULT_WS_PORT, MQTT_DEFAULT_WSS_PORT from span_panel_api.mqtt.connection import AsyncMqttBridge -from span_panel_api.mqtt.homie import HomieDeviceConsumer from span_panel_api.mqtt.models import MqttClientConfig from span_panel_api.protocol import ( PanelCapability, @@ -180,18 +177,18 @@ def test_ignores_set_topics(self): class TestHomieCircuitSnapshot: def test_circuit_id_normalization(self): - from span_panel_api.mqtt.const import normalize_circuit_id + from span_panel_api._impl.schema_0.const import normalize_circuit_id assert normalize_circuit_id("aabbccdd-1122-3344-5566-778899001122") == "aabbccdd11223344556677889900112" + "2" def test_circuit_id_denormalization(self): - from span_panel_api.mqtt.const import denormalize_circuit_id + from span_panel_api._impl.schema_0.const import denormalize_circuit_id result = denormalize_circuit_id("aabbccdd11223344556677889900112" + "2") assert result == "aabbccdd-1122-3344-5566-778899001122" def test_denormalize_non_uuid(self): - from span_panel_api.mqtt.const import denormalize_circuit_id + from span_panel_api._impl.schema_0.const import denormalize_circuit_id # Non-32-char strings pass through unchanged assert denormalize_circuit_id("short") == "short" From d10da42b54fe76d13a3935cb218a99b8c6e8b10f Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:19:54 -0700 Subject: [PATCH 003/115] feat(schema_0): add SchemaZeroAdapter composing accumulator and consumer --- src/span_panel_api/_impl/schema_0/__init__.py | 10 ++- src/span_panel_api/_impl/schema_0/adapter.py | 67 +++++++++++++++++++ tests/test_schema_zero_adapter.py | 53 +++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 src/span_panel_api/_impl/schema_0/adapter.py create mode 100644 tests/test_schema_zero_adapter.py diff --git a/src/span_panel_api/_impl/schema_0/__init__.py b/src/span_panel_api/_impl/schema_0/__init__.py index 63c37b9..a5314d1 100644 --- a/src/span_panel_api/_impl/schema_0/__init__.py +++ b/src/span_panel_api/_impl/schema_0/__init__.py @@ -1 +1,9 @@ -"""Flat-schema (Homie v5) parsing implementation. Not public API.""" +"""Flat-schema adapter package (data-model-version absent).""" + +from span_panel_api._impl.schema_0.adapter import SchemaZeroAdapter + +# Inclusive lower bound, exclusive upper bound. The flat schema publishes no +# data-model-version, so it is treated as the synthetic version 0 range. +SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = (">=0", "<1.0") + +__all__ = ["SUPPORTS_DATA_MODEL_VERSIONS", "SchemaZeroAdapter"] diff --git a/src/span_panel_api/_impl/schema_0/adapter.py b/src/span_panel_api/_impl/schema_0/adapter.py new file mode 100644 index 0000000..e03a3c1 --- /dev/null +++ b/src/span_panel_api/_impl/schema_0/adapter.py @@ -0,0 +1,67 @@ +"""Flat-schema (data-model-version absent) adapter. + +Composes the existing accumulator + consumer and owns the flat wire format: +a single Homie device whose node ids are circuit UUIDs and capability names. +Nothing outside this package constructs a flat-schema topic. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + +from span_panel_api._impl.schema_0.accumulator import HomiePropertyAccumulator +from span_panel_api._impl.schema_0.const import PROPERTY_SET_TOPIC_FMT, TYPE_CORE, WILDCARD_TOPIC_FMT +from span_panel_api._impl.schema_0.consumer import HomieDeviceConsumer +from span_panel_api._impl.schema_0.field_metadata import build_field_metadata + +if TYPE_CHECKING: + from span_panel_api.models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot + + +class SchemaZeroAdapter: + """Parser for the flat single-device schema (firmware r202603-r202627).""" + + schema_major = "schema_0" + SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = (">=0", "<1.0") + + def __init__(self, serial_number: str, panel_size: int) -> None: + self._serial_number = serial_number + self._accumulator = HomiePropertyAccumulator(serial_number) + self._consumer = HomieDeviceConsumer(self._accumulator, panel_size) + + def topics_to_subscribe(self) -> list[str]: + return [WILDCARD_TOPIC_FMT.format(serial=self._serial_number)] + + def handle_message(self, topic: str, payload: str) -> None: + self._consumer.handle_message(topic, payload) + + def is_ready(self) -> bool: + return self._consumer.is_ready() + + def build_snapshot(self) -> SpanPanelSnapshot: + return self._consumer.build_snapshot() + + def build_field_metadata(self, schema_types: HomieSchemaTypes) -> dict[str, FieldMetadata]: + return build_field_metadata(schema_types) + + def circuit_nodes_missing_names(self) -> list[str]: + return self._consumer.circuit_nodes_missing_names() + + def find_node_by_type(self, type_str: str) -> str | None: + return self._consumer.find_node_by_type(type_str) + + def set_circuit_relay_topic(self, circuit_id: str) -> str: + return PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=circuit_id, prop="relay") + + def set_circuit_priority_topic(self, circuit_id: str) -> str: + return PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=circuit_id, prop="shed-priority") + + def set_dominant_power_source_topic(self) -> str | None: + core_node = self._consumer.find_node_by_type(TYPE_CORE) + if core_node is None: + return None + return PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=core_node, prop="dominant-power-source") + + def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]: + return self._consumer.register_property_callback(callback) diff --git a/tests/test_schema_zero_adapter.py b/tests/test_schema_zero_adapter.py new file mode 100644 index 0000000..0c6f2d8 --- /dev/null +++ b/tests/test_schema_zero_adapter.py @@ -0,0 +1,53 @@ +"""SchemaZeroAdapter contract tests. + +The adapter owns every piece of flat-schema knowledge that used to live in +SpanMqttClient: which topics to subscribe to, and how to address a settable +property. These tests pin the exact topic strings, because the flat wire format +is fixed by shipped firmware and must not drift. +""" + +from __future__ import annotations + +import pytest + +from span_panel_api._impl.schema_0 import SchemaZeroAdapter +from span_panel_api.protocol import SchemaAdapter + +SERIAL = "sim-40t-001" + + +@pytest.fixture +def adapter() -> SchemaZeroAdapter: + return SchemaZeroAdapter(serial_number=SERIAL, panel_size=40) + + +def test_satisfies_the_protocol(adapter: SchemaZeroAdapter) -> None: + assert isinstance(adapter, SchemaAdapter) + + +def test_declares_its_dispatch_key_and_range(adapter: SchemaZeroAdapter) -> None: + assert adapter.schema_major == "schema_0" + assert adapter.SUPPORTS_DATA_MODEL_VERSIONS == (">=0", "<1.0") + + +def test_subscribes_to_the_single_panel_wildcard(adapter: SchemaZeroAdapter) -> None: + """Flat schema is one device, so one wildcard captures everything.""" + assert adapter.topics_to_subscribe() == [f"ebus/5/{SERIAL}/#"] + + +def test_circuit_setter_topics_address_the_panel_device(adapter: SchemaZeroAdapter) -> None: + circuit = "ac3dccda46a94b98878a227df6fed588" + assert adapter.set_circuit_relay_topic(circuit) == f"ebus/5/{SERIAL}/{circuit}/relay/set" + assert adapter.set_circuit_priority_topic(circuit) == f"ebus/5/{SERIAL}/{circuit}/shed-priority/set" + + +def test_dominant_power_source_topic_is_none_before_the_core_node_is_known( + adapter: SchemaZeroAdapter, +) -> None: + """The core node id is discovered from $description, so it is unavailable + until a description has been routed through handle_message.""" + assert adapter.set_dominant_power_source_topic() is None + + +def test_is_not_ready_before_any_message(adapter: SchemaZeroAdapter) -> None: + assert adapter.is_ready() is False From 89c6267654cb7933d7462915f8848c016d39fa5e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:50:22 -0700 Subject: [PATCH 004/115] refactor(mqtt): delegate parsing and topic construction to SchemaAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SpanMqttClient now builds its parser via an injectable adapter_factory (defaulting to SchemaZeroAdapter) at the two points that need panel_size — connect() and the pre-rebuild reconnect path — instead of constructing HomiePropertyAccumulator/HomieDeviceConsumer and formatting flat-schema topics inline. _require_homie becomes _require_adapter with the same exception type and message; set_dominant_power_source still raises SpanPanelServerError when no core node is found, now driven by the adapter's set_dominant_power_source_topic() returning None. The _on_connection_change resubscribe path uses a plain None-check instead of _require_adapter() to avoid introducing a new raise path inside a connection callback where none existed before. --- src/span_panel_api/mqtt/client.py | 112 +++++++++++++++------------ tests/test_mqtt_client_connection.py | 89 +++++++++++++++++---- tests/test_mqtt_connect_flow.py | 25 +++--- tests/test_mqtt_homie.py | 31 ++++---- 4 files changed, 160 insertions(+), 97 deletions(-) diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 394f5b1..530343d 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -1,6 +1,6 @@ """SPAN Panel MQTT client. -Composes AsyncMqttBridge and HomieDeviceConsumer to implement +Composes AsyncMqttBridge and a SchemaAdapter to implement SpanPanelClientProtocol, CircuitControlProtocol, PanelControlProtocol, and StreamingCapableProtocol. """ @@ -13,15 +13,13 @@ import logging import time -from span_panel_api._impl.schema_0.accumulator import HomiePropertyAccumulator -from span_panel_api._impl.schema_0.const import PROPERTY_SET_TOPIC_FMT, TYPE_CORE, WILDCARD_TOPIC_FMT -from span_panel_api._impl.schema_0.consumer import HomieDeviceConsumer -from span_panel_api._impl.schema_0.field_metadata import build_field_metadata, log_schema_drift +from span_panel_api._impl.schema_0 import SchemaZeroAdapter +from span_panel_api._impl.schema_0.field_metadata import log_schema_drift from ..auth import get_homie_schema from ..exceptions import SpanPanelConnectionError, SpanPanelServerError, SpanPanelStaleDataError from ..models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot -from ..protocol import PanelCapability +from ..protocol import PanelCapability, SchemaAdapter from .connection import AsyncMqttBridge from .const import MQTT_READY_TIMEOUT_S from .models import MqttClientConfig @@ -44,16 +42,17 @@ def __init__( broker_config: MqttClientConfig, snapshot_interval: float = 1.0, panel_http_port: int = 80, + adapter_factory: Callable[[str, int], SchemaAdapter] = SchemaZeroAdapter, ) -> None: self._host = host self._serial_number = serial_number self._broker_config = broker_config self._snapshot_interval = snapshot_interval self._panel_http_port = panel_http_port + self._adapter_factory = adapter_factory self._bridge: AsyncMqttBridge | None = None - self._accumulator: HomiePropertyAccumulator | None = None - self._homie: HomieDeviceConsumer | None = None + self._adapter: SchemaAdapter | None = None self._streaming = False self._snapshot_callbacks: list[Callable[[SpanPanelSnapshot], Awaitable[None]]] = [] self._connection_callbacks: list[Callable[[bool], None]] = [] @@ -70,11 +69,25 @@ def __init__( # rebuild. Schema cannot change within a session, so caching is safe. self._panel_size: int | None = None - def _require_homie(self) -> HomieDeviceConsumer: - """Return the HomieDeviceConsumer, raising if not yet connected.""" - if self._homie is None: + def _build_adapter(self, panel_size: int) -> SchemaAdapter: + """Construct the parser for this session. + + Called from connect() and from the reconnect path — the only two + places a parser is built today. + """ + self._adapter = self._adapter_factory(self._serial_number, panel_size) + return self._adapter + + @property + def adapter(self) -> SchemaAdapter | None: + """Return the active schema adapter, or None before connect().""" + return self._adapter + + def _require_adapter(self) -> SchemaAdapter: + """Return the SchemaAdapter, raising if not yet connected.""" + if self._adapter is None: raise SpanPanelConnectionError("Client not connected — call connect() first") - return self._homie + return self._adapter # -- SpanPanelClientProtocol ------------------------------------------- @@ -109,7 +122,7 @@ async def connect(self) -> None: 1. Fetch Homie schema to determine panel size 2. Create AsyncMqttBridge with broker credentials 3. Connect to MQTT broker - 4. Subscribe to ebus/5/{serial}/# + 4. Subscribe to the adapter's topics 5. Wait for $state==ready and $description parsed Raises: @@ -122,8 +135,7 @@ async def connect(self) -> None: # Fetch schema to determine panel size and build field metadata schema = await get_homie_schema(self._host, port=self._panel_http_port) self._panel_size = schema.panel_size - self._accumulator = HomiePropertyAccumulator(self._serial_number) - self._homie = HomieDeviceConsumer(self._accumulator, schema.panel_size) + self._build_adapter(schema.panel_size) # Detect schema drift from previous connection new_hash = schema.types_schema_hash @@ -139,7 +151,7 @@ async def connect(self) -> None: self._previous_schema_types = schema.types # Build transport-agnostic field metadata from schema - self._field_metadata = build_field_metadata(schema.types) + self._field_metadata = self._require_adapter().build_field_metadata(schema.types) _LOGGER.debug( "MQTT: Creating bridge to %s:%s (serial=%s)", @@ -176,9 +188,10 @@ async def connect(self) -> None: _LOGGER.debug("MQTT: Broker connected, subscribing...") # Subscribe to all device topics - wildcard = WILDCARD_TOPIC_FMT.format(serial=self._serial_number) - self._bridge.subscribe(wildcard, qos=0) - _LOGGER.debug("MQTT: Subscribed to %s, waiting for Homie ready...", wildcard) + topics = self._require_adapter().topics_to_subscribe() + for topic in topics: + self._bridge.subscribe(topic, qos=0) + _LOGGER.debug("MQTT: Subscribed to %s, waiting for Homie ready...", topics) # Wait for Homie ready state try: @@ -205,14 +218,13 @@ async def close(self) -> None: if self._bridge is not None: await self._bridge.disconnect() self._bridge = None - self._accumulator = None self._live = False async def ping(self) -> bool: """Check if MQTT connection is alive and device is ready.""" - if self._bridge is None or self._homie is None: + if self._bridge is None or self._adapter is None: return False - return self._bridge.is_connected() and self._homie.is_ready() + return self._bridge.is_connected() and self._adapter.is_ready() def register_connection_callback(self, callback: Callable[[bool], None]) -> Callable[[], None]: """Subscribe to broker connection state transitions. @@ -244,13 +256,13 @@ async def get_snapshot(self) -> SpanPanelSnapshot: No network call — snapshot is built from in-memory property values when the liveness checks pass. """ - if self._bridge is None or self._homie is None: + if self._bridge is None or self._adapter is None: raise SpanPanelStaleDataError("Client not connected — call connect() first") if not self._bridge.is_connected(): raise SpanPanelStaleDataError("MQTT broker disconnected") - if not self._homie.is_ready(): + if not self._adapter.is_ready(): raise SpanPanelStaleDataError("Homie device not ready") - return self._homie.build_snapshot() + return self._adapter.build_snapshot() # -- CircuitControlProtocol -------------------------------------------- @@ -261,33 +273,32 @@ async def set_circuit_relay(self, circuit_id: str, state: str) -> None: circuit_id: Dashless UUID (matches wire format) state: "OPEN" or "CLOSED" """ - topic = PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=circuit_id, prop="relay") + topic = self._require_adapter().set_circuit_relay_topic(circuit_id) if self._bridge is not None: self._bridge.publish(topic, state, qos=1) async def set_circuit_priority(self, circuit_id: str, priority: str) -> None: - """Publish shed-priority change for a circuit. + """Publish a circuit priority change. Args: circuit_id: Dashless UUID (matches wire format) priority: v2 enum value (NEVER, SOC_THRESHOLD, OFF_GRID) """ - topic = PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=circuit_id, prop="shed-priority") + topic = self._require_adapter().set_circuit_priority_topic(circuit_id) if self._bridge is not None: self._bridge.publish(topic, priority, qos=1) # -- PanelControlProtocol ---------------------------------------------- async def set_dominant_power_source(self, value: str) -> None: - """Publish dominant-power-source change to the core node. + """Publish a dominant power source change for the panel. Args: value: DPS enum value (GRID, BATTERY, NONE, GENERATOR, PV) """ - core_node = self._require_homie().find_node_by_type(TYPE_CORE) - if core_node is None: + topic = self._require_adapter().set_dominant_power_source_topic() + if topic is None: raise SpanPanelServerError("Core node not found in panel topology") - topic = PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=core_node, prop="dominant-power-source") if self._bridge is not None: self._bridge.publish(topic, value, qos=1) @@ -324,18 +335,18 @@ async def stop_streaming(self) -> None: def _on_message(self, topic: str, payload: str) -> None: """Handle incoming MQTT message (called from asyncio loop).""" - homie = self._homie - if homie is None: + adapter = self._adapter + if adapter is None: return - was_ready = homie.is_ready() - homie.handle_message(topic, payload) + was_ready = adapter.is_ready() + adapter.handle_message(topic, payload) # Check if device just became ready - if not was_ready and homie.is_ready() and self._ready_event is not None: + if not was_ready and adapter.is_ready() and self._ready_event is not None: self._ready_event.set() # Dispatch snapshot callbacks if streaming - if self._streaming and homie.is_ready() and self._loop is not None: + if self._streaming and adapter.is_ready() and self._loop is not None: if self._snapshot_interval <= 0: # Real-time mode — dispatch immediately, no debounce. self._create_dispatch_task() @@ -360,9 +371,9 @@ def _on_connection_change(self, connected: bool) -> None: # edge-only (see the guard after this block). if connected: _LOGGER.debug("MQTT connection established") - if self._bridge is not None: - wildcard = WILDCARD_TOPIC_FMT.format(serial=self._serial_number) - self._bridge.subscribe(wildcard, qos=0) + if self._bridge is not None and self._adapter is not None: + for topic in self._adapter.topics_to_subscribe(): + self._bridge.subscribe(topic, qos=0) else: _LOGGER.debug("MQTT connection lost") # Cancel any pending snapshot-debounce timer so it cannot @@ -402,27 +413,26 @@ def _on_pre_rebuild(self) -> None: # because connect() never completed. return _LOGGER.debug("Pre-rebuild — resetting Homie accumulator") - self._accumulator = HomiePropertyAccumulator(self._serial_number) - self._homie = HomieDeviceConsumer(self._accumulator, self._panel_size) + self._build_adapter(self._panel_size) async def _wait_for_circuit_names(self, timeout: float) -> None: """Wait for all circuit-like nodes to have a ``name`` property. Retained MQTT messages may arrive after the Homie device transitions - to ready. This polls the HomieDeviceConsumer at short intervals and + to ready. This polls the schema adapter at short intervals and returns as soon as all circuit names are populated, or when the timeout elapses (non-fatal — entities will use fallback names). """ - homie = self._require_homie() + adapter = self._require_adapter() deadline = time.monotonic() + timeout while time.monotonic() < deadline: - missing = homie.circuit_nodes_missing_names() + missing = adapter.circuit_nodes_missing_names() if not missing: _LOGGER.debug("All circuit names received") return await asyncio.sleep(_CIRCUIT_NAMES_POLL_INTERVAL_S) - still_missing = homie.circuit_nodes_missing_names() + still_missing = adapter.circuit_nodes_missing_names() if still_missing: _LOGGER.warning( "Timed out waiting for circuit names (%d still missing): %s", @@ -475,15 +485,15 @@ async def _dispatch_snapshot(self) -> None: snapshot to subscribers after the fact. """ bridge = self._bridge - homie = self._homie - if bridge is None or not bridge.is_connected() or homie is None or not homie.is_ready(): + adapter = self._adapter + if bridge is None or not bridge.is_connected() or adapter is None or not adapter.is_ready(): _LOGGER.debug( "Skipping stale snapshot dispatch (bridge_connected=%s, homie_ready=%s)", bridge is not None and bridge.is_connected(), - homie is not None and homie.is_ready(), + adapter is not None and adapter.is_ready(), ) return - snapshot = homie.build_snapshot() + snapshot = adapter.build_snapshot() for cb in list(self._snapshot_callbacks): try: await cb(snapshot) diff --git a/tests/test_mqtt_client_connection.py b/tests/test_mqtt_client_connection.py index cfa2527..a7742fc 100644 --- a/tests/test_mqtt_client_connection.py +++ b/tests/test_mqtt_client_connection.py @@ -9,7 +9,6 @@ from span_panel_api.exceptions import SpanPanelError, SpanPanelStaleDataError from span_panel_api.models import SpanPanelSnapshot from span_panel_api._impl.schema_0.const import WILDCARD_TOPIC_FMT -from span_panel_api._impl.schema_0.consumer import HomieDeviceConsumer from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.connection import AsyncMqttBridge from span_panel_api.mqtt.models import MqttClientConfig @@ -44,15 +43,15 @@ def subscribe(self, topic: str, qos: int = 0) -> None: self.subscribed_topics.append((topic, qos)) -class _FakeHomie(HomieDeviceConsumer): - """Minimal Homie stub for get_snapshot() tests. +class _FakeAdapter: + """Minimal SchemaAdapter stub for get_snapshot()/resubscribe tests. - Bypasses HomieDeviceConsumer.__init__ — only is_ready() and - build_snapshot() are invoked on this stub. + Only the methods SpanMqttClient actually calls on the adapter are + implemented: is_ready() and build_snapshot() for liveness/dispatch + tests, topics_to_subscribe() for resubscribe tests. """ def __init__(self, ready: bool = True, snapshot: SpanPanelSnapshot | None = None) -> None: - # Intentionally do not call super().__init__ — avoids accumulator setup. self._ready_flag = ready self._snapshot = snapshot @@ -61,9 +60,12 @@ def is_ready(self) -> bool: def build_snapshot(self) -> SpanPanelSnapshot: if self._snapshot is None: - raise RuntimeError("_FakeHomie: no snapshot configured") + raise RuntimeError("_FakeAdapter: no snapshot configured") return self._snapshot + def topics_to_subscribe(self) -> list[str]: + return [WILDCARD_TOPIC_FMT.format(serial="test-serial")] + class TestRegisterConnectionCallback: """Callback subscription API — structural only (fan-out is tested in Task 4).""" @@ -210,6 +212,7 @@ def test_reconnect_triggers_resubscribe_and_callback(self) -> None: client = _make_client() bridge = _FakeBridge(connected=True) client._bridge = bridge + client._adapter = _FakeAdapter() client._live = False # was offline calls: list[bool] = [] client.register_connection_callback(calls.append) @@ -231,6 +234,7 @@ def test_resubscribe_fires_even_on_duplicate_true(self) -> None: client = _make_client() bridge = _FakeBridge(connected=True) client._bridge = bridge + client._adapter = _FakeAdapter() client._live = True # already online calls: list[bool] = [] client.register_connection_callback(calls.append) @@ -278,7 +282,7 @@ class TestGetSnapshotLiveness: async def test_raises_stale_when_bridge_none(self) -> None: client = _make_client() client._bridge = None - client._homie = _FakeHomie(ready=True) + client._adapter = _FakeAdapter(ready=True) with pytest.raises(SpanPanelStaleDataError) as exc_info: await client.get_snapshot() @@ -287,7 +291,7 @@ async def test_raises_stale_when_bridge_none(self) -> None: async def test_raises_stale_when_homie_none(self) -> None: client = _make_client() client._bridge = _FakeBridge(connected=True) - client._homie = None + client._adapter = None with pytest.raises(SpanPanelStaleDataError) as exc_info: await client.get_snapshot() @@ -296,7 +300,7 @@ async def test_raises_stale_when_homie_none(self) -> None: async def test_raises_stale_when_broker_disconnected(self) -> None: client = _make_client() client._bridge = _FakeBridge(connected=False) - client._homie = _FakeHomie(ready=True) + client._adapter = _FakeAdapter(ready=True) with pytest.raises(SpanPanelStaleDataError) as exc_info: await client.get_snapshot() @@ -305,7 +309,7 @@ async def test_raises_stale_when_broker_disconnected(self) -> None: async def test_raises_stale_when_homie_not_ready(self) -> None: client = _make_client() client._bridge = _FakeBridge(connected=True) - client._homie = _FakeHomie(ready=False) + client._adapter = _FakeAdapter(ready=False) with pytest.raises(SpanPanelStaleDataError) as exc_info: await client.get_snapshot() @@ -315,7 +319,7 @@ async def test_returns_snapshot_when_fully_live(self) -> None: sentinel = _make_sentinel_snapshot() client = _make_client() client._bridge = _FakeBridge(connected=True) - client._homie = _FakeHomie(ready=True, snapshot=sentinel) + client._adapter = _FakeAdapter(ready=True, snapshot=sentinel) snapshot = await client.get_snapshot() assert snapshot is sentinel @@ -323,7 +327,7 @@ async def test_returns_snapshot_when_fully_live(self) -> None: async def test_raised_exception_is_span_panel_error(self) -> None: client = _make_client() client._bridge = None - client._homie = None + client._adapter = None with pytest.raises(SpanPanelError): await client.get_snapshot() @@ -355,7 +359,7 @@ async def test_dispatch_snapshot_bails_when_bridge_disconnected(self, caplog: py snapshot_sentinel = _make_sentinel_snapshot() client = _make_client() client._bridge = _FakeBridge(connected=False) - client._homie = _FakeHomie(ready=True, snapshot=snapshot_sentinel) + client._adapter = _FakeAdapter(ready=True, snapshot=snapshot_sentinel) calls: list[SpanPanelSnapshot] = [] @@ -375,7 +379,7 @@ async def test_dispatch_snapshot_bails_when_homie_not_ready(self) -> None: snapshot_sentinel = _make_sentinel_snapshot() client = _make_client() client._bridge = _FakeBridge(connected=True) - client._homie = _FakeHomie(ready=False, snapshot=snapshot_sentinel) + client._adapter = _FakeAdapter(ready=False, snapshot=snapshot_sentinel) calls: list[SpanPanelSnapshot] = [] @@ -393,7 +397,7 @@ async def test_dispatch_snapshot_delivers_when_live(self) -> None: snapshot_sentinel = _make_sentinel_snapshot() client = _make_client() client._bridge = _FakeBridge(connected=True) - client._homie = _FakeHomie(ready=True, snapshot=snapshot_sentinel) + client._adapter = _FakeAdapter(ready=True, snapshot=snapshot_sentinel) calls: list[SpanPanelSnapshot] = [] @@ -429,3 +433,56 @@ def cancel(self) -> None: assert handle.cancelled is True assert client._snapshot_timer is None + + +def test_adapter_is_none_before_connect() -> None: + """The parser needs panel_size, which only connect() knows, so there is no + adapter until then — mirroring today's `self._homie = None`.""" + from span_panel_api.mqtt.client import SpanMqttClient + from span_panel_api.mqtt.models import MqttClientConfig + + client = SpanMqttClient( + "192.0.2.10", "sim-40t-001", MqttClientConfig(broker_host="192.0.2.10", username="test", password="test") + ) + + assert client.adapter is None + + +def test_client_defaults_to_the_schema_zero_factory() -> None: + from span_panel_api._impl.schema_0 import SchemaZeroAdapter + from span_panel_api.mqtt.client import SpanMqttClient + from span_panel_api.mqtt.models import MqttClientConfig + + client = SpanMqttClient( + "192.0.2.10", "sim-40t-001", MqttClientConfig(broker_host="192.0.2.10", username="test", password="test") + ) + + assert client._adapter_factory is SchemaZeroAdapter + + +def test_injected_factory_receives_serial_and_panel_size() -> None: + """The factory must be called with the panel_size discovered at connect, + not a placeholder — panel_size drives unmapped-tab computation.""" + from span_panel_api._impl.schema_0 import SchemaZeroAdapter + from span_panel_api.mqtt.client import SpanMqttClient + from span_panel_api.mqtt.models import MqttClientConfig + + seen: list[tuple[str, int]] = [] + + def factory(serial_number: str, panel_size: int) -> SchemaZeroAdapter: + seen.append((serial_number, panel_size)) + return SchemaZeroAdapter(serial_number=serial_number, panel_size=panel_size) + + client = SpanMqttClient( + "192.0.2.10", + "sim-40t-001", + MqttClientConfig(broker_host="192.0.2.10", username="test", password="test"), + adapter_factory=factory, + ) + + # Exercise the construction path directly rather than standing up a broker. + client._panel_size = 40 + client._build_adapter(40) + + assert seen == [("sim-40t-001", 40)] + assert isinstance(client.adapter, SchemaZeroAdapter) diff --git a/tests/test_mqtt_connect_flow.py b/tests/test_mqtt_connect_flow.py index 8ac7371..6297218 100644 --- a/tests/test_mqtt_connect_flow.py +++ b/tests/test_mqtt_connect_flow.py @@ -725,7 +725,8 @@ class TestSpanMqttClientAccumulatorReset: @pytest.mark.asyncio async def test_pre_rebuild_resets_accumulator(self, mqtt_client_mock: MagicMock) -> None: - """`_on_pre_rebuild` replaces accumulator and consumer with fresh instances.""" + """`_on_pre_rebuild` replaces the adapter (and its internal accumulator/ + consumer) with a fresh instance.""" client = _make_span_client() connect_task = asyncio.create_task(client.connect()) @@ -734,21 +735,18 @@ async def test_pre_rebuild_resets_accumulator(self, mqtt_client_mock: MagicMock) client._on_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") await asyncio.wait_for(connect_task, timeout=5.0) - original_accumulator = client._accumulator - original_homie = client._homie - assert original_accumulator is not None - assert original_homie is not None - # Accumulator is in a ready-ish state from the simulated Homie messages. - assert original_homie.is_ready() is True + original_adapter = client._adapter + assert original_adapter is not None + # Adapter is in a ready-ish state from the simulated Homie messages. + assert original_adapter.is_ready() is True # Trigger the pre-rebuild hook directly — same call the bridge makes. client._on_pre_rebuild() - # New accumulator / consumer instances, fresh state. - assert client._accumulator is not original_accumulator - assert client._homie is not original_homie - assert client._homie is not None - assert client._homie.is_ready() is False + # New adapter instance, fresh state. + assert client._adapter is not original_adapter + assert client._adapter is not None + assert client._adapter.is_ready() is False await client.close() @@ -785,8 +783,7 @@ async def test_pre_rebuild_before_connect_is_noop(self) -> None: # _panel_size is None because connect() never ran. client._on_pre_rebuild() # No exception, no state changes. - assert client._accumulator is None - assert client._homie is None + assert client._adapter is None # --------------------------------------------------------------------------- diff --git a/tests/test_mqtt_homie.py b/tests/test_mqtt_homie.py index fb0389a..e732eea 100644 --- a/tests/test_mqtt_homie.py +++ b/tests/test_mqtt_homie.py @@ -22,6 +22,7 @@ import pytest +from span_panel_api._impl.schema_0 import SchemaZeroAdapter from span_panel_api._impl.schema_0.accumulator import HomiePropertyAccumulator from span_panel_api._impl.schema_0.const import ( TOPIC_PREFIX, @@ -1016,6 +1017,7 @@ async def test_set_circuit_relay_publishes(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, panel_size=32) mock_bridge = MagicMock() client._bridge = mock_bridge @@ -1034,6 +1036,7 @@ async def test_set_circuit_priority_publishes(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, panel_size=32) mock_bridge = MagicMock() client._bridge = mock_bridge @@ -1052,13 +1055,12 @@ async def test_set_dominant_power_source_publishes(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) - client._accumulator = HomiePropertyAccumulator(SERIAL) - client._homie = HomieDeviceConsumer(client._accumulator, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, panel_size=32) # Populate the homie description so core node is known desc = _make_description(_core_description()) - client._homie.handle_message(f"{PREFIX}/$state", HOMIE_STATE_READY) - client._homie.handle_message(f"{PREFIX}/$description", desc) + client._adapter.handle_message(f"{PREFIX}/$state", HOMIE_STATE_READY) + client._adapter.handle_message(f"{PREFIX}/$description", desc) mock_bridge = MagicMock() client._bridge = mock_bridge @@ -1078,8 +1080,7 @@ async def test_set_dominant_power_source_no_core_node_raises(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) - client._accumulator = HomiePropertyAccumulator(SERIAL) - client._homie = HomieDeviceConsumer(client._accumulator, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, panel_size=32) # No description loaded — core node not found with pytest.raises(SpanPanelServerError, match="Core node not found"): @@ -1098,14 +1099,13 @@ async def test_get_snapshot_returns_homie_state(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) - client._accumulator = HomiePropertyAccumulator(SERIAL) - client._homie = HomieDeviceConsumer(client._accumulator, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, panel_size=32) client._bridge = _ConnectedBridge() - # Manually ready the homie consumer - client._homie.handle_message(f"{PREFIX}/$state", "ready") - client._homie.handle_message(f"{PREFIX}/$description", _make_description(_core_description())) - client._homie.handle_message(f"{PREFIX}/core/software-version", "test-fw") + # Manually ready the adapter + client._adapter.handle_message(f"{PREFIX}/$state", "ready") + client._adapter.handle_message(f"{PREFIX}/$description", _make_description(_core_description())) + client._adapter.handle_message(f"{PREFIX}/core/software-version", "test-fw") snapshot = await client.get_snapshot() assert snapshot.serial_number == SERIAL @@ -1129,11 +1129,10 @@ async def test_ping_true_when_connected_and_ready(self): mock_bridge = MagicMock() mock_bridge.is_connected.return_value = True client._bridge = mock_bridge - client._accumulator = HomiePropertyAccumulator(SERIAL) - client._homie = HomieDeviceConsumer(client._accumulator, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, panel_size=32) - client._homie.handle_message(f"{PREFIX}/$state", "ready") - client._homie.handle_message(f"{PREFIX}/$description", _make_description(_core_description())) + client._adapter.handle_message(f"{PREFIX}/$state", "ready") + client._adapter.handle_message(f"{PREFIX}/$description", _make_description(_core_description())) assert await client.ping() is True From 52c327627cd1a76e2ea02d5e5b251ebb7d7edfa8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:04:54 -0700 Subject: [PATCH 005/115] feat(adapters): discover schema adapters via entry points Add discover_adapters(), a process-lifetime-cached registry populated from the span_panel_api.schema_adapters entry-point group, plus its self-registration for SchemaZeroAdapter. Not yet wired into the factory (Task 6). --- pyproject.toml | 3 +++ src/span_panel_api/adapters.py | 41 ++++++++++++++++++++++++++++++++ tests/test_adapters_discovery.py | 16 +++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 src/span_panel_api/adapters.py create mode 100644 tests/test_adapters_discovery.py diff --git a/pyproject.toml b/pyproject.toml index 8765872..715f110 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,9 @@ Issues = "https://github.com/SpanPanel/span-panel-api/issues" [project.scripts] format-markdown = "scripts.format_markdown:main" +[project.entry-points."span_panel_api.schema_adapters"] +schema_0 = "span_panel_api._impl.schema_0:SchemaZeroAdapter" + [dependency-groups] dev = [ "pytest>=9.0.2", diff --git a/src/span_panel_api/adapters.py b/src/span_panel_api/adapters.py new file mode 100644 index 0000000..173992a --- /dev/null +++ b/src/span_panel_api/adapters.py @@ -0,0 +1,41 @@ +"""Adapter discovery via the `span_panel_api.schema_adapters` entry-point group. + +Called once per process on the first create_span_client(). A venv change needs a +process restart regardless, so a process-lifetime cache is correct. +""" + +from __future__ import annotations + +from importlib.metadata import entry_points +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from span_panel_api.protocol import SchemaAdapter + +_LOGGER = logging.getLogger(__name__) +_ENTRY_POINT_GROUP = "span_panel_api.schema_adapters" +_REGISTRY: dict[str, type[SchemaAdapter]] | None = None + + +def discover_adapters() -> dict[str, type[SchemaAdapter]]: + """Load and cache every adapter class registered under the entry-point group.""" + global _REGISTRY # pylint: disable=global-statement # process-lifetime cache by design + if _REGISTRY is None: + registry: dict[str, type[SchemaAdapter]] = {} + for ep in entry_points(group=_ENTRY_POINT_GROUP): + if ep.name in registry: + _LOGGER.warning("Duplicate schema adapter entry point %r; keeping the first found", ep.name) + continue + try: + registry[ep.name] = ep.load() + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.exception("Failed to load schema adapter entry point %r", ep.name) + _REGISTRY = registry + return _REGISTRY + + +def _reset_adapter_cache() -> None: + """Test hook. Not public API.""" + global _REGISTRY # pylint: disable=global-statement # test hook for the cache above + _REGISTRY = None diff --git a/tests/test_adapters_discovery.py b/tests/test_adapters_discovery.py new file mode 100644 index 0000000..3ad0433 --- /dev/null +++ b/tests/test_adapters_discovery.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from span_panel_api.adapters import _reset_adapter_cache, discover_adapters + + +def test_discovers_the_self_registered_schema_zero_adapter() -> None: + _reset_adapter_cache() + registry = discover_adapters() + + assert "schema_0" in registry + assert registry["schema_0"].__name__ == "SchemaZeroAdapter" + + +def test_registry_is_cached_across_calls() -> None: + _reset_adapter_cache() + assert discover_adapters() is discover_adapters() From ea1533053319f9375992bf4192af31e0e26edd28 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:19:33 -0700 Subject: [PATCH 006/115] feat(factory): dispatch to a discovered adapter and expose diagnostics Tier 1 dispatch: data-model-version absence selects schema_0; presence selects schema_{major}, raising SpanPanelAdapterMissingError if no matching adapter is installed. create_span_client() resolves the adapter class via discover_adapters() and passes it as SpanMqttClient's adapter_factory. Adds schema_major, data_model_version, schema_dispatch_reason, and available_adapters diagnostics properties, and logs the selection on connect(). --- src/span_panel_api/factory.py | 47 ++++++++++++- src/span_panel_api/mqtt/client.py | 37 +++++++++- tests/test_factory_dispatch.py | 110 ++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 tests/test_factory_dispatch.py diff --git a/src/span_panel_api/factory.py b/src/span_panel_api/factory.py index a36bef2..f8006e2 100644 --- a/src/span_panel_api/factory.py +++ b/src/span_panel_api/factory.py @@ -7,17 +7,51 @@ from __future__ import annotations import logging +import re +from .adapters import discover_adapters from .auth import register_v2 from .detection import detect_api_version -from .exceptions import SpanPanelAuthError +from .exceptions import SpanPanelAdapterMissingError, SpanPanelAuthError from .mqtt.client import SpanMqttClient from .mqtt.models import MqttClientConfig +from .protocol import SchemaAdapter _LOGGER = logging.getLogger(__name__) _V2_CLIENT_NAME = "span-panel-api" +_DMV_PATTERN = re.compile(r"^(\d+)\.\d+(?:\.\d+)?$") + + +def _select_adapter_key(data_model_version: str | None) -> tuple[str, str]: + """Tier 1 dispatch: the panel's data-model-version selects the adapter major. + + Absence is the flat-schema signal — the property was introduced by the same + firmware that introduced the parent/child model, so a panel that does not + publish it is speaking the flat schema. + """ + if data_model_version is None: + return "schema_0", "data-model-version absent (flat schema)" + + match = _DMV_PATTERN.match(data_model_version) + if match is None: + return "schema_0", f"unrecognised data-model-version={data_model_version!r}, assuming flat" + + return ( + f"schema_{int(match.group(1))}", + f"data-model-version={data_model_version!r}", + ) + + +def _resolve_adapter_cls(key: str, reason: str) -> type[SchemaAdapter]: + """Look up the discovered adapter class for `key`, or raise with the installed list.""" + registry = discover_adapters() + adapter_cls = registry.get(key) + if adapter_cls is None: + raise SpanPanelAdapterMissingError(needed=key, reason=reason, available=sorted(registry)) + return adapter_cls + async def create_span_client( host: str, @@ -68,6 +102,15 @@ async def create_span_client( if serial_number is None: raise SpanPanelAuthError("serial_number is required for MQTT transport but could not be determined") - client = SpanMqttClient(host, serial_number, mqtt_config, panel_http_port=port) + # Phase 0: the factory does not fetch the Homie schema, so no panel can + # report a data-model-version yet. `None` is the correct observation for + # every panel currently in the field — Phase 1 adds the fetch. + data_model_version: str | None = None + adapter_key, dispatch_reason = _select_adapter_key(data_model_version) + adapter_cls = _resolve_adapter_cls(adapter_key, dispatch_reason) + + client = SpanMqttClient(host, serial_number, mqtt_config, panel_http_port=port, adapter_factory=adapter_cls) + client._data_model_version = data_model_version # pylint: disable=protected-access + client._schema_dispatch_reason = dispatch_reason # pylint: disable=protected-access await client.connect() return client diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 530343d..3058ce8 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -10,12 +10,14 @@ import asyncio from collections.abc import Awaitable, Callable import contextlib +from importlib.metadata import version import logging import time from span_panel_api._impl.schema_0 import SchemaZeroAdapter from span_panel_api._impl.schema_0.field_metadata import log_schema_drift +from ..adapters import discover_adapters from ..auth import get_homie_schema from ..exceptions import SpanPanelConnectionError, SpanPanelServerError, SpanPanelStaleDataError from ..models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot @@ -68,6 +70,10 @@ def __init__( # Homie accumulator with the same panel size after a transport-level # rebuild. Schema cannot change within a session, so caching is safe. self._panel_size: int | None = None + # Diagnostics — the factory overwrites these after adapter selection. + # Defaults describe a client built directly (bypassing create_span_client). + self._data_model_version: str | None = None + self._schema_dispatch_reason: str = "not dispatched" def _build_adapter(self, panel_size: int) -> SchemaAdapter: """Construct the parser for this session. @@ -83,6 +89,26 @@ def adapter(self) -> SchemaAdapter | None: """Return the active schema adapter, or None before connect().""" return self._adapter + @property + def schema_major(self) -> str | None: + """Return the active adapter's schema major, or None before connect().""" + return self._adapter.schema_major if self._adapter is not None else None + + @property + def data_model_version(self) -> str | None: + """Return the panel's observed data-model-version, or None if absent/not yet dispatched.""" + return self._data_model_version + + @property + def schema_dispatch_reason(self) -> str: + """Return the human-readable reason the active adapter was selected.""" + return self._schema_dispatch_reason + + @property + def available_adapters(self) -> list[str]: + """Return the sorted keys of every schema adapter discovered in this process.""" + return sorted(discover_adapters()) + def _require_adapter(self) -> SchemaAdapter: """Return the SchemaAdapter, raising if not yet connected.""" if self._adapter is None: @@ -135,7 +161,16 @@ async def connect(self) -> None: # Fetch schema to determine panel size and build field metadata schema = await get_homie_schema(self._host, port=self._panel_http_port) self._panel_size = schema.panel_size - self._build_adapter(schema.panel_size) + adapter = self._build_adapter(schema.panel_size) + + _LOGGER.info( + "MQTT adapter selected: %s (span-panel-api %s)\n data-model-version: %r\n reason: %s\n available: %s", + adapter.schema_major, + version("span-panel-api"), + self._data_model_version, + self._schema_dispatch_reason, + sorted(discover_adapters()), + ) # Detect schema drift from previous connection new_hash = schema.types_schema_hash diff --git a/tests/test_factory_dispatch.py b/tests/test_factory_dispatch.py new file mode 100644 index 0000000..d6c4456 --- /dev/null +++ b/tests/test_factory_dispatch.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from span_panel_api._impl.schema_0 import SchemaZeroAdapter +from span_panel_api.adapters import _reset_adapter_cache +from span_panel_api.exceptions import SpanPanelAdapterMissingError +from span_panel_api.factory import _select_adapter_key +from span_panel_api.mqtt.client import SpanMqttClient +from span_panel_api.mqtt.models import MqttClientConfig + +from conftest import MINIMAL_DESCRIPTION, SERIAL, TOPIC_PREFIX_SERIAL + + +def test_absent_data_model_version_selects_schema_zero() -> None: + key, reason = _select_adapter_key(None) + assert key == "schema_0" + assert "absent" in reason + + +@pytest.mark.parametrize("dmv", ["1.0", "1.4", "2.0"]) +def test_present_data_model_version_requests_a_numbered_adapter(dmv: str) -> None: + key, reason = _select_adapter_key(dmv) + assert key == f"schema_{dmv.split('.')[0]}" + assert dmv in reason + + +def test_missing_adapter_raises_with_the_installed_list() -> None: + from span_panel_api.factory import _resolve_adapter_cls + + _reset_adapter_cache() + with pytest.raises(SpanPanelAdapterMissingError) as exc: + _resolve_adapter_cls("schema_1", "data-model-version='1.0'") + + assert exc.value.needed == "schema_1" + assert "schema_0" in exc.value.available + + +# --------------------------------------------------------------------------- +# create_span_client — wiring the selected adapter class into SpanMqttClient +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_span_client_wires_schema_zero_adapter_and_diagnostics() -> None: + """The factory must pass the resolved adapter *class* as adapter_factory, + and assign the dispatch diagnostics onto the constructed client before + connect() runs.""" + from span_panel_api.factory import create_span_client + + _reset_adapter_cache() + config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") + + with patch("span_panel_api.factory.SpanMqttClient") as mock_cls: + mock_client = mock_cls.return_value + mock_client.connect = AsyncMock() + + result = await create_span_client( + "192.168.1.1", + mqtt_config=config, + serial_number="test-serial", + ) + + assert result is mock_client + _, kwargs = mock_cls.call_args + assert kwargs["adapter_factory"] is SchemaZeroAdapter + mock_client.connect.assert_awaited_once() + # Diagnostics were assigned directly on the instance ahead of connect(). + assert mock_client._data_model_version is None # pylint: disable=protected-access + assert "absent" in mock_client._schema_dispatch_reason # pylint: disable=protected-access + + +# --------------------------------------------------------------------------- +# SpanMqttClient diagnostics properties — before and after connect() +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_diagnostics_properties_before_and_after_connect(mqtt_client_mock: MagicMock) -> None: + _reset_adapter_cache() + config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") + client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + + # Before connect(): no adapter yet. Defaults describe a client built + # directly, bypassing create_span_client. + assert client.adapter is None + assert client.schema_major is None + assert client.data_model_version is None + assert client.schema_dispatch_reason == "not dispatched" + assert "schema_0" in client.available_adapters + + # Simulate what create_span_client does after adapter selection, ahead of connect(). + client._data_model_version = None # pylint: disable=protected-access + client._schema_dispatch_reason = "data-model-version absent (flat schema)" # pylint: disable=protected-access + + connect_task = asyncio.create_task(client.connect()) + await asyncio.sleep(0.05) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") + await asyncio.wait_for(connect_task, timeout=5.0) + + assert isinstance(client.adapter, SchemaZeroAdapter) + assert client.schema_major == "schema_0" + assert client.data_model_version is None + assert client.schema_dispatch_reason == "data-model-version absent (flat schema)" + + await client.close() From b909e5228865edda6a85df20d3975154dc576f3d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:28:24 -0700 Subject: [PATCH 007/115] test: guard the public API surface across the Phase 0 restructure --- tests/test_public_api_unchanged.py | 81 ++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/test_public_api_unchanged.py diff --git a/tests/test_public_api_unchanged.py b/tests/test_public_api_unchanged.py new file mode 100644 index 0000000..f228cc2 --- /dev/null +++ b/tests/test_public_api_unchanged.py @@ -0,0 +1,81 @@ +"""Guard: Phase 0 is a restructure, so the public surface must not move. + +The HA integration pins span-panel-api and imports these names directly. If this +test fails, the change is no longer Phase 0 — it is a breaking release. +""" + +from __future__ import annotations + +import span_panel_api + +# Source of truth: src/span_panel_api/__init__.py __all__ (transcribed in full, +# not trimmed, per Phase 0 Task 7's instruction to reconcile against the real file +# rather than an earlier hand-transcribed listing). +EXPECTED_PUBLIC_API = { + # Protocols + "CircuitControlProtocol", + "PanelCapability", + "PanelControlProtocol", + "SpanPanelClientProtocol", + "StreamingCapableProtocol", + # Metadata + "FieldMetadata", + "HomieSchemaTypes", + # Snapshots + "SpanBatterySnapshot", + "SpanCircuitSnapshot", + "SpanEvseSnapshot", + "SpanPVSnapshot", + "SpanPanelSnapshot", + # Factory + "create_span_client", + # Detection + "DetectionResult", + "detect_api_version", + # v2 auth + "V2AuthResponse", + "V2HomieSchema", + "V2StatusInfo", + "delete_fqdn", + "download_ca_cert", + "get_fqdn", + "get_homie_schema", + "get_v2_status", + "register_fqdn", + "regenerate_passphrase", + "register_v2", + # Transport + "HomieLifecycle", + "HomiePropertyAccumulator", + "MqttClientConfig", + "SpanMqttClient", + # Phase validation + "PhaseDistribution", + "are_tabs_opposite_phase", + "get_phase_distribution", + "get_tab_phase", + "suggest_balanced_pairing", + "validate_solar_tabs", + # Exceptions + "SpanPanelAPIError", + "SpanPanelAuthError", + "SpanPanelConnectionError", + "SpanPanelError", + "SpanPanelServerError", + "SpanPanelStaleDataError", + "SpanPanelTimeoutError", + "SpanPanelValidationError", +} + + +def test_all_is_unchanged() -> None: + missing = EXPECTED_PUBLIC_API - set(span_panel_api.__all__) + assert not missing, f"Phase 0 removed public names: {sorted(missing)}" + + extra = set(span_panel_api.__all__) - EXPECTED_PUBLIC_API + assert not extra, f"Phase 0 added undocumented public names: {sorted(extra)}" + + +def test_every_exported_name_is_importable() -> None: + for name in span_panel_api.__all__: + assert hasattr(span_panel_api, name), f"{name} is in __all__ but not importable" From 1b9b578df7bdb3b97ba1e9b2e6fc6b28f12e8a08 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:49:50 -0700 Subject: [PATCH 008/115] refactor(mqtt): drop the last schema_0 import from the transport bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move log_schema_drift out of _impl/schema_0/field_metadata.py into a new top-level span_panel_api/schema_drift.py — it only diffs two HomieSchemaTypes dicts and never touched the schema_0 TYPE_* constants, so it was already schema-agnostic and belongs at the bootstrap level, not behind the adapter seam. mqtt/client.py now imports it from there instead of from _impl/schema_0, so the transport no longer reaches into the adapter package for anything but its default adapter_factory (SchemaZeroAdapter), which stays as the plan-sanctioned default. build_field_metadata and the TYPE_* imports are untouched; the now-unused _LOGGER/import logging left behind in field_metadata.py are removed. Also documents two behaviors that were previously implicit or wrong: - SpanMqttClient.adapter's docstring now notes that transport rebuild replaces the adapter instance, so property callbacks registered on the old instance do not survive and must be re-registered. - SchemaAdapter's docstring no longer claims every method is called by SpanMqttClient — find_node_by_type and register_property_callback are never called from the bootstrap; they exist for external consumers. --- .../_impl/schema_0/field_metadata.py | 54 --------------- src/span_panel_api/mqtt/client.py | 10 ++- src/span_panel_api/protocol.py | 7 +- src/span_panel_api/schema_drift.py | 65 +++++++++++++++++++ tests/test_field_metadata.py | 3 +- 5 files changed, 80 insertions(+), 59 deletions(-) create mode 100644 src/span_panel_api/schema_drift.py diff --git a/src/span_panel_api/_impl/schema_0/field_metadata.py b/src/span_panel_api/_impl/schema_0/field_metadata.py index 500389b..ab83d68 100644 --- a/src/span_panel_api/_impl/schema_0/field_metadata.py +++ b/src/span_panel_api/_impl/schema_0/field_metadata.py @@ -15,8 +15,6 @@ from __future__ import annotations -import logging - from span_panel_api._impl.schema_0.const import ( TYPE_BESS, TYPE_CIRCUIT, @@ -30,8 +28,6 @@ ) from span_panel_api.models import FieldMetadata, HomieSchemaTypes -_LOGGER = logging.getLogger(__name__) - # --------------------------------------------------------------------------- # Static mapping: (node_type, property_id) → snapshot field path # @@ -177,53 +173,3 @@ def build_field_metadata( result[field_path] = FieldMetadata(unit=unit, datatype=datatype) return result - - -def log_schema_drift( - previous: HomieSchemaTypes, - current: HomieSchemaTypes, -) -> None: - """Log property-level differences between two schema versions. - - Called by the client when the schema hash changes between connections. - All Homie-specific detail stays in this module — the integration never - sees this output, only the transport-agnostic field metadata. - """ - prev_types = set(previous.keys()) - curr_types = set(current.keys()) - - for node_type in sorted(curr_types - prev_types): - _LOGGER.debug("Schema drift: new node type '%s'", node_type) - - for node_type in sorted(prev_types - curr_types): - _LOGGER.debug("Schema drift: removed node type '%s'", node_type) - - for node_type in sorted(prev_types & curr_types): - prev_props = previous[node_type] - curr_props = current[node_type] - if not isinstance(prev_props, dict) or not isinstance(curr_props, dict): - continue - - for prop_id in sorted(set(curr_props) - set(prev_props)): - _LOGGER.debug("Schema drift: new property '%s/%s'", node_type, prop_id) - - for prop_id in sorted(set(prev_props) - set(curr_props)): - _LOGGER.debug("Schema drift: removed property '%s/%s'", node_type, prop_id) - - for prop_id in sorted(set(prev_props) & set(curr_props)): - prev_def = prev_props[prop_id] - curr_def = curr_props[prop_id] - if not isinstance(prev_def, dict) or not isinstance(curr_def, dict): - continue - for attr in ("datatype", "unit", "format"): - old_val = prev_def.get(attr) - new_val = curr_def.get(attr) - if old_val != new_val: - _LOGGER.debug( - "Schema drift: '%s/%s' %s changed: '%s' → '%s'", - node_type, - prop_id, - attr, - old_val, - new_val, - ) diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 3058ce8..72132ba 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -15,7 +15,7 @@ import time from span_panel_api._impl.schema_0 import SchemaZeroAdapter -from span_panel_api._impl.schema_0.field_metadata import log_schema_drift +from span_panel_api.schema_drift import log_schema_drift from ..adapters import discover_adapters from ..auth import get_homie_schema @@ -86,7 +86,13 @@ def _build_adapter(self, panel_size: int) -> SchemaAdapter: @property def adapter(self) -> SchemaAdapter | None: - """Return the active schema adapter, or None before connect().""" + """Return the active schema adapter, or None before connect(). + + On transport rebuild (see ``_on_pre_rebuild``), the adapter instance + is replaced with a fresh one — any callback registered via + ``adapter.register_property_callback(...)`` on the old instance does + not survive the rebuild and must be re-registered on the new one. + """ return self._adapter @property diff --git a/src/span_panel_api/protocol.py b/src/span_panel_api/protocol.py index 68fd44c..9675293 100644 --- a/src/span_panel_api/protocol.py +++ b/src/span_panel_api/protocol.py @@ -83,8 +83,11 @@ async def stop_streaming(self) -> None: ... class SchemaAdapter(Protocol): """Parser for a single data-model-major schema. - Frozen within a major version of this package. Every method here is called - by SpanMqttClient; nothing else in the bootstrap knows the wire format. + Frozen within a major version of this package. Most methods here are + called by SpanMqttClient, which is the only bootstrap code that knows + the wire format; ``find_node_by_type`` and ``register_property_callback`` + are not called by the bootstrap at all — they exist for external + consumers of the active adapter. """ schema_major: str diff --git a/src/span_panel_api/schema_drift.py b/src/span_panel_api/schema_drift.py new file mode 100644 index 0000000..13f3c62 --- /dev/null +++ b/src/span_panel_api/schema_drift.py @@ -0,0 +1,65 @@ +"""Diagnostic logging for Homie schema drift between panel sessions. + +Schema-agnostic: operates purely on ``HomieSchemaTypes`` dicts (a mapping of +node type to property definitions) and has no dependency on flat-schema +(schema_0) internals. Lives at the bootstrap level so ``span_panel_api.mqtt`` +can call it without importing anything from ``_impl/schema_0``. +""" + +from __future__ import annotations + +import logging + +from span_panel_api.models import HomieSchemaTypes + +_LOGGER = logging.getLogger(__name__) + + +def log_schema_drift( + previous: HomieSchemaTypes, + current: HomieSchemaTypes, +) -> None: + """Log property-level differences between two schema versions. + + Called by the client when the schema hash changes between connections. + All Homie-specific detail stays in this module — the integration never + sees this output, only the transport-agnostic field metadata. + """ + prev_types = set(previous.keys()) + curr_types = set(current.keys()) + + for node_type in sorted(curr_types - prev_types): + _LOGGER.debug("Schema drift: new node type '%s'", node_type) + + for node_type in sorted(prev_types - curr_types): + _LOGGER.debug("Schema drift: removed node type '%s'", node_type) + + for node_type in sorted(prev_types & curr_types): + prev_props = previous[node_type] + curr_props = current[node_type] + if not isinstance(prev_props, dict) or not isinstance(curr_props, dict): + continue + + for prop_id in sorted(set(curr_props) - set(prev_props)): + _LOGGER.debug("Schema drift: new property '%s/%s'", node_type, prop_id) + + for prop_id in sorted(set(prev_props) - set(curr_props)): + _LOGGER.debug("Schema drift: removed property '%s/%s'", node_type, prop_id) + + for prop_id in sorted(set(prev_props) & set(curr_props)): + prev_def = prev_props[prop_id] + curr_def = curr_props[prop_id] + if not isinstance(prev_def, dict) or not isinstance(curr_def, dict): + continue + for attr in ("datatype", "unit", "format"): + old_val = prev_def.get(attr) + new_val = curr_def.get(attr) + if old_val != new_val: + _LOGGER.debug( + "Schema drift: '%s/%s' %s changed: '%s' → '%s'", + node_type, + prop_id, + attr, + old_val, + new_val, + ) diff --git a/tests/test_field_metadata.py b/tests/test_field_metadata.py index ac19e2a..333776f 100644 --- a/tests/test_field_metadata.py +++ b/tests/test_field_metadata.py @@ -5,7 +5,8 @@ import logging from span_panel_api.models import FieldMetadata -from span_panel_api._impl.schema_0.field_metadata import build_field_metadata, log_schema_drift +from span_panel_api._impl.schema_0.field_metadata import build_field_metadata +from span_panel_api.schema_drift import log_schema_drift def _make_schema_types() -> dict[str, dict[str, object]]: From 93b206b484473e4c12f2e8cbf6ede0ff09b6d7ce Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:00:28 -0700 Subject: [PATCH 009/115] feat!: remove the three flat-schema names from the public API BREAKING CHANGE: HomieLifecycle, HomiePropertyAccumulator and HomieDeviceConsumer are no longer exported from span_panel_api or span_panel_api.mqtt. All three are flat-schema-specific, not Homie-convention-level: HomiePropertyAccumulator filters every topic against a single device's prefix and stores node -> prop, which drops nearly every message under parent/child; HomieLifecycle's members are not Homie 5 $state values but a consumer-side progression encoding 'one description received => ready', which is the flat readiness model. HomieDeviceConsumer is the flat parser itself. They were re-exported only because the bootstrap re-exported them; nothing consumes them (the HA integration references none of the three, and this repo's own tests import them from their defining modules). Removing them severs two of the three bootstrap -> _impl edges that prevent shipping schema_0 as a separate distribution. 3.0 is already a breaking bump. test_public_api_unchanged.py is a two-way pin, so it is edited here in the same commit; its docstring now frames it as a deliberate-change guard rather than a no-change guard. --- src/span_panel_api/__init__.py | 4 +--- src/span_panel_api/mqtt/__init__.py | 10 ++++------ tests/test_public_api_unchanged.py | 14 +++++++++----- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/span_panel_api/__init__.py b/src/span_panel_api/__init__.py index 62ab74f..fb8af21 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -40,7 +40,7 @@ V2HomieSchema, V2StatusInfo, ) -from .mqtt import HomieLifecycle, HomiePropertyAccumulator, MqttClientConfig, SpanMqttClient +from .mqtt import MqttClientConfig, SpanMqttClient from .phase_validation import ( PhaseDistribution, are_tabs_opposite_phase, @@ -93,8 +93,6 @@ "regenerate_passphrase", "register_v2", # Transport - "HomieLifecycle", - "HomiePropertyAccumulator", "MqttClientConfig", "SpanMqttClient", # Phase validation diff --git a/src/span_panel_api/mqtt/__init__.py b/src/span_panel_api/mqtt/__init__.py index 8be5e51..9580610 100644 --- a/src/span_panel_api/mqtt/__init__.py +++ b/src/span_panel_api/mqtt/__init__.py @@ -1,7 +1,8 @@ -"""SPAN Panel MQTT/Homie transport.""" +"""SPAN Panel MQTT/Homie transport. -from span_panel_api._impl.schema_0.accumulator import HomieLifecycle, HomiePropertyAccumulator -from span_panel_api._impl.schema_0.consumer import HomieDeviceConsumer +Schema-agnostic: nothing here imports a parsing implementation. The flat-schema +parser is reached only through the `span_panel_api.schema_adapters` entry point. +""" from .async_client import AsyncMQTTClient from .client import SpanMqttClient @@ -11,9 +12,6 @@ __all__ = [ "AsyncMQTTClient", "AsyncMqttBridge", - "HomieDeviceConsumer", - "HomieLifecycle", - "HomiePropertyAccumulator", "MqttClientConfig", "SpanMqttClient", ] diff --git a/tests/test_public_api_unchanged.py b/tests/test_public_api_unchanged.py index f228cc2..19503d7 100644 --- a/tests/test_public_api_unchanged.py +++ b/tests/test_public_api_unchanged.py @@ -1,7 +1,13 @@ -"""Guard: Phase 0 is a restructure, so the public surface must not move. +"""Guard: the public surface only moves on purpose. -The HA integration pins span-panel-api and imports these names directly. If this -test fails, the change is no longer Phase 0 — it is a breaking release. +The HA integration pins span-panel-api and imports these names directly, so a +failure here means either an accidental break or a deliberate one whose record +belongs in the same commit. The set below is a two-way pin — it fails on both +removals and additions — and editing it is how a break gets acknowledged. + +Phase 0 held it fixed. Phase 1 deliberately breaks it (3.0): the three +flat-schema names below were removed, because the bootstrap can no longer import +a parsing implementation to re-export. """ from __future__ import annotations @@ -45,8 +51,6 @@ "regenerate_passphrase", "register_v2", # Transport - "HomieLifecycle", - "HomiePropertyAccumulator", "MqttClientConfig", "SpanMqttClient", # Phase validation From 590c6ff4e19a77c93a0c338a687bfc8f3b4d32dd Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:05:52 -0700 Subject: [PATCH 010/115] refactor(mqtt): resolve the default adapter through discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transport's last import of a parsing implementation. mqtt/client.py imported SchemaZeroAdapter purely to use as the default adapter_factory, which is unsupportable once schema_0 ships as a separate distribution: the import would fail in exactly the adapter-less install that entry-point discovery exists to support. adapter_factory becomes optional. When it is None, _build_adapter resolves DEFAULT_ADAPTER_KEY through discover_adapters() and raises SpanPanelAdapterMissingError if nothing answers to it. Resolution is lazy by design: constructing a client must not require an adapter to be installed, only building a parser must — so 'import span_panel_api.mqtt.client' now succeeds with every schema_0 module blocked, verified directly. Behaviour is unchanged for every existing caller. A directly constructed client still parses the flat schema; it just reaches the parser by name rather than by import. resolve_adapter() moves to adapters.py (client cannot import from factory — factory imports client) and factory's _resolve_adapter_cls now delegates to it, so a missing adapter produces one error message from one place. Also declares SchemaAdapter.__init__. Construction was always part of the contract — the transport resolves an adapter class and calls it — but Phase 0's Callable[[str, int], SchemaAdapter] alias left the signature unchecked against implementations; mypy caught this the moment the seam became a type[]. The signature carries panel_size, a flat-schema concept, and is the part of the protocol expected to change with schema_1; stating it makes that a visible break rather than a runtime TypeError. --- src/span_panel_api/adapters.py | 22 +++++++++ src/span_panel_api/factory.py | 16 ++---- src/span_panel_api/mqtt/client.py | 21 ++++++-- src/span_panel_api/protocol.py | 16 ++++++ tests/test_adapters_discovery.py | 73 +++++++++++++++++++++++++++- tests/test_factory_dispatch.py | 4 +- tests/test_mqtt_client_connection.py | 14 +++++- tests/test_protocol_conformance.py | 20 ++++++++ 8 files changed, 164 insertions(+), 22 deletions(-) diff --git a/src/span_panel_api/adapters.py b/src/span_panel_api/adapters.py index 173992a..4f0c70b 100644 --- a/src/span_panel_api/adapters.py +++ b/src/span_panel_api/adapters.py @@ -10,6 +10,8 @@ import logging from typing import TYPE_CHECKING +from span_panel_api.exceptions import SpanPanelAdapterMissingError + if TYPE_CHECKING: from span_panel_api.protocol import SchemaAdapter @@ -17,6 +19,12 @@ _ENTRY_POINT_GROUP = "span_panel_api.schema_adapters" _REGISTRY: dict[str, type[SchemaAdapter]] | None = None +# The adapter key for panels that publish no data-model-version. This is a +# bootstrap-level fact — Tier 1 dispatch reads absence as "flat schema" — not an +# import of the flat adapter. The bootstrap knows the *name*; whether anything +# answers to it is entry-point discovery's problem. +DEFAULT_ADAPTER_KEY = "schema_0" + def discover_adapters() -> dict[str, type[SchemaAdapter]]: """Load and cache every adapter class registered under the entry-point group.""" @@ -35,6 +43,20 @@ def discover_adapters() -> dict[str, type[SchemaAdapter]]: return _REGISTRY +def resolve_adapter(key: str, reason: str) -> type[SchemaAdapter]: + """Return the discovered adapter class for `key`, or raise naming what is installed. + + The one place a missing adapter turns into a named error. Both the factory's + Tier 1 dispatch and the transport's default path go through here so a user + whose panel outruns their install sees the same message either way. + """ + registry = discover_adapters() + adapter_cls = registry.get(key) + if adapter_cls is None: + raise SpanPanelAdapterMissingError(needed=key, reason=reason, available=sorted(registry)) + return adapter_cls + + def _reset_adapter_cache() -> None: """Test hook. Not public API.""" global _REGISTRY # pylint: disable=global-statement # test hook for the cache above diff --git a/src/span_panel_api/factory.py b/src/span_panel_api/factory.py index f8006e2..7c2f9aa 100644 --- a/src/span_panel_api/factory.py +++ b/src/span_panel_api/factory.py @@ -9,13 +9,12 @@ import logging import re -from .adapters import discover_adapters +from .adapters import resolve_adapter from .auth import register_v2 from .detection import detect_api_version -from .exceptions import SpanPanelAdapterMissingError, SpanPanelAuthError +from .exceptions import SpanPanelAuthError from .mqtt.client import SpanMqttClient from .mqtt.models import MqttClientConfig -from .protocol import SchemaAdapter _LOGGER = logging.getLogger(__name__) @@ -44,15 +43,6 @@ def _select_adapter_key(data_model_version: str | None) -> tuple[str, str]: ) -def _resolve_adapter_cls(key: str, reason: str) -> type[SchemaAdapter]: - """Look up the discovered adapter class for `key`, or raise with the installed list.""" - registry = discover_adapters() - adapter_cls = registry.get(key) - if adapter_cls is None: - raise SpanPanelAdapterMissingError(needed=key, reason=reason, available=sorted(registry)) - return adapter_cls - - async def create_span_client( host: str, passphrase: str | None = None, @@ -107,7 +97,7 @@ async def create_span_client( # every panel currently in the field — Phase 1 adds the fetch. data_model_version: str | None = None adapter_key, dispatch_reason = _select_adapter_key(data_model_version) - adapter_cls = _resolve_adapter_cls(adapter_key, dispatch_reason) + adapter_cls = resolve_adapter(adapter_key, dispatch_reason) client = SpanMqttClient(host, serial_number, mqtt_config, panel_http_port=port, adapter_factory=adapter_cls) client._data_model_version = data_model_version # pylint: disable=protected-access diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 72132ba..142cc2d 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -14,10 +14,9 @@ import logging import time -from span_panel_api._impl.schema_0 import SchemaZeroAdapter from span_panel_api.schema_drift import log_schema_drift -from ..adapters import discover_adapters +from ..adapters import DEFAULT_ADAPTER_KEY, discover_adapters, resolve_adapter from ..auth import get_homie_schema from ..exceptions import SpanPanelConnectionError, SpanPanelServerError, SpanPanelStaleDataError from ..models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot @@ -44,7 +43,7 @@ def __init__( broker_config: MqttClientConfig, snapshot_interval: float = 1.0, panel_http_port: int = 80, - adapter_factory: Callable[[str, int], SchemaAdapter] = SchemaZeroAdapter, + adapter_factory: Callable[[str, int], SchemaAdapter] | None = None, ) -> None: self._host = host self._serial_number = serial_number @@ -80,8 +79,22 @@ def _build_adapter(self, panel_size: int) -> SchemaAdapter: Called from connect() and from the reconnect path — the only two places a parser is built today. + + Resolving the default here rather than in ``__init__`` is deliberate: + constructing a client must not require an adapter to be installed, only + building a parser must. That keeps ``import span_panel_api.mqtt.client`` + working in an adapter-less install — the configuration entry-point + discovery exists to support — and puts the failure at the point where it + is actionable. + + Raises: + SpanPanelAdapterMissingError: No adapter_factory was supplied and no + package registers the default adapter key. """ - self._adapter = self._adapter_factory(self._serial_number, panel_size) + factory = self._adapter_factory + if factory is None: + factory = resolve_adapter(DEFAULT_ADAPTER_KEY, "no adapter_factory supplied to SpanMqttClient") + self._adapter = factory(self._serial_number, panel_size) return self._adapter @property diff --git a/src/span_panel_api/protocol.py b/src/span_panel_api/protocol.py index 9675293..2faea5b 100644 --- a/src/span_panel_api/protocol.py +++ b/src/span_panel_api/protocol.py @@ -93,6 +93,22 @@ class SchemaAdapter(Protocol): schema_major: str SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] + def __init__(self, serial_number: str, panel_size: int) -> None: + """Construct a parser for one panel session. + + Declared because construction is part of the contract: the transport + resolves an adapter *class* from the entry-point registry and calls it. + Phase 0 typed the seam as ``Callable[[str, int], SchemaAdapter]``, which + left the signature unchecked against implementations; stating it here + puts it back under the type checker. + + ``panel_size`` is a flat-schema concept the transport fetches on the + adapter's behalf, so this signature is the one part of the protocol + expected to change when schema_1 lands — see the Phase 1 follow-ups, + item 2. It is stated rather than hidden precisely so that change is a + visible protocol break rather than a silent runtime TypeError. + """ + def topics_to_subscribe(self) -> list[str]: ... def handle_message(self, topic: str, payload: str) -> None: ... diff --git a/tests/test_adapters_discovery.py b/tests/test_adapters_discovery.py index 3ad0433..8d574ae 100644 --- a/tests/test_adapters_discovery.py +++ b/tests/test_adapters_discovery.py @@ -1,6 +1,13 @@ from __future__ import annotations -from span_panel_api.adapters import _reset_adapter_cache, discover_adapters +from unittest.mock import patch + +import pytest + +from span_panel_api.adapters import DEFAULT_ADAPTER_KEY, _reset_adapter_cache, discover_adapters, resolve_adapter +from span_panel_api.exceptions import SpanPanelAdapterMissingError +from span_panel_api.mqtt.client import SpanMqttClient +from span_panel_api.mqtt.models import MqttClientConfig def test_discovers_the_self_registered_schema_zero_adapter() -> None: @@ -14,3 +21,67 @@ def test_discovers_the_self_registered_schema_zero_adapter() -> None: def test_registry_is_cached_across_calls() -> None: _reset_adapter_cache() assert discover_adapters() is discover_adapters() + + +# --------------------------------------------------------------------------- +# The default adapter path — the bootstrap must not import a parser to get one +# --------------------------------------------------------------------------- + + +def _client(adapter_factory: object = None) -> SpanMqttClient: + config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") + kwargs = {} if adapter_factory is None else {"adapter_factory": adapter_factory} + return SpanMqttClient("panel.local", "SERIAL123", config, **kwargs) # type: ignore[arg-type] + + +def test_default_factory_resolves_the_flat_adapter_through_discovery() -> None: + """No adapter_factory means "resolve the default key", not "import SchemaZeroAdapter".""" + _reset_adapter_cache() + client = _client() + + adapter = client._build_adapter(32) + + assert adapter.schema_major == DEFAULT_ADAPTER_KEY + assert type(adapter) is discover_adapters()[DEFAULT_ADAPTER_KEY] + + +def test_constructing_a_client_does_not_require_an_installed_adapter() -> None: + """Construction must stay adapter-free; only building a parser needs one. + + This is the property that lets the bootstrap ship without a parser at all. + """ + with patch("span_panel_api.adapters._REGISTRY", {}): + _client() # must not raise + + +def test_building_a_parser_without_any_adapter_raises_by_name() -> None: + """The adapter-less install's failure mode: a named error, not ModuleNotFoundError.""" + _reset_adapter_cache() + client = _client() + + with patch("span_panel_api.adapters._REGISTRY", {}), pytest.raises(SpanPanelAdapterMissingError) as exc: + client._build_adapter(32) + + assert exc.value.needed == DEFAULT_ADAPTER_KEY + assert exc.value.available == [] + + +def test_an_explicit_factory_bypasses_discovery_entirely() -> None: + """Injection still wins — used by the factory's Tier 1 dispatch and by tests.""" + _reset_adapter_cache() + real_cls = discover_adapters()[DEFAULT_ADAPTER_KEY] + client = _client(adapter_factory=real_cls) + + with patch("span_panel_api.adapters.discover_adapters", side_effect=AssertionError("must not be consulted")): + adapter = client._build_adapter(32) + + assert type(adapter) is real_cls + + +def test_resolve_adapter_names_what_is_installed() -> None: + _reset_adapter_cache() + with pytest.raises(SpanPanelAdapterMissingError) as exc: + resolve_adapter("schema_9", "made-up key") + + assert exc.value.needed == "schema_9" + assert DEFAULT_ADAPTER_KEY in exc.value.available diff --git a/tests/test_factory_dispatch.py b/tests/test_factory_dispatch.py index d6c4456..05872a0 100644 --- a/tests/test_factory_dispatch.py +++ b/tests/test_factory_dispatch.py @@ -29,11 +29,11 @@ def test_present_data_model_version_requests_a_numbered_adapter(dmv: str) -> Non def test_missing_adapter_raises_with_the_installed_list() -> None: - from span_panel_api.factory import _resolve_adapter_cls + from span_panel_api.adapters import resolve_adapter _reset_adapter_cache() with pytest.raises(SpanPanelAdapterMissingError) as exc: - _resolve_adapter_cls("schema_1", "data-model-version='1.0'") + resolve_adapter("schema_1", "data-model-version='1.0'") assert exc.value.needed == "schema_1" assert "schema_0" in exc.value.available diff --git a/tests/test_mqtt_client_connection.py b/tests/test_mqtt_client_connection.py index a7742fc..b35f0d4 100644 --- a/tests/test_mqtt_client_connection.py +++ b/tests/test_mqtt_client_connection.py @@ -448,7 +448,16 @@ def test_adapter_is_none_before_connect() -> None: assert client.adapter is None -def test_client_defaults_to_the_schema_zero_factory() -> None: +def test_client_defaults_to_the_flat_adapter() -> None: + """Unchanged behaviour, different mechanism. + + Phase 0 pinned the default as an identity check against an imported + SchemaZeroAdapter. Phase 1 resolves it through entry-point discovery + instead, so the default is deliberately *unset* at construction and only + materialises when a parser is built. Asserting the built adapter rather + than the stored factory keeps the guarantee that mattered — a directly + constructed client still parses the flat schema. + """ from span_panel_api._impl.schema_0 import SchemaZeroAdapter from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.models import MqttClientConfig @@ -457,7 +466,8 @@ def test_client_defaults_to_the_schema_zero_factory() -> None: "192.0.2.10", "sim-40t-001", MqttClientConfig(broker_host="192.0.2.10", username="test", password="test") ) - assert client._adapter_factory is SchemaZeroAdapter + assert client._adapter_factory is None + assert isinstance(client._build_adapter(40), SchemaZeroAdapter) def test_injected_factory_receives_serial_and_panel_size() -> None: diff --git a/tests/test_protocol_conformance.py b/tests/test_protocol_conformance.py index dc7eb0e..50101fc 100644 --- a/tests/test_protocol_conformance.py +++ b/tests/test_protocol_conformance.py @@ -81,6 +81,26 @@ def test_schema_adapter_declares_its_class_attributes() -> None: assert name in SchemaAdapter.__annotations__, f"SchemaAdapter is missing attribute {name}" +def test_schema_adapter_construction_signature_matches_its_implementation() -> None: + """Construction is part of the contract, so it must be checked like the rest. + + `hasattr(SchemaAdapter, "__init__")` is vacuous — every object has one. The + assertion with teeth is that the protocol's declared signature and the + installed adapter's actual signature agree, which is what the transport + depends on when it calls a class resolved from the entry-point registry. + """ + import inspect + + from span_panel_api._impl.schema_0 import SchemaZeroAdapter + from span_panel_api.protocol import SchemaAdapter + + declared = list(inspect.signature(SchemaAdapter.__init__).parameters) + implemented = list(inspect.signature(SchemaZeroAdapter.__init__).parameters) + + assert declared == ["self", "serial_number", "panel_size"] + assert implemented == declared, f"SchemaZeroAdapter.__init__{implemented} does not match the protocol {declared}" + + def test_adapter_missing_error_reports_what_is_installed() -> None: from span_panel_api.exceptions import SpanPanelAdapterMissingError From cac27c09c64516a05cdf4d7e5cb4074fae7fa82f Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:08:19 -0700 Subject: [PATCH 011/115] fix(factory): refuse an unreadable data-model-version instead of assuming flat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _select_adapter_key mapped every unparseable value to schema_0 with the reason 'assuming flat'. A panel publishing '1', 'v1.0' or '1.0-beta' would therefore be handed to the flat parser, which does not fail — it produces plausible but wrong power and energy figures in Home Assistant. A wrong number the user cannot see is strictly worse than an error they can. Dispatch now distinguishes three cases rather than two: - Absent: the flat-schema signal, unchanged. The property was introduced by the firmware that introduced parent/child, so absence is real evidence and must stay non-fatal — it is the common case in the field today. - Present with an extractable major, canonical or not ('1', '1.0-beta'): dispatch on that major and log the deviation. This is not a guess; the major is what selects the adapter and it was read, not assumed. Refusing here would take a panel offline over a formatting difference, while the warning still surfaces a new firmware format before it becomes an outage. - Present with no extractable major: raise. Adds SpanPanelSchemaVersionError rather than reusing SpanPanelAdapterMissingError, because the remedies differ. A missing adapter is a known schema with no installed parser — install the package. This is a schema whose major cannot be determined, so no adapter can even be named. Dead in Phase 0 (data_model_version is hardcoded None) and live the moment Tier 1 reads a real value. --- src/span_panel_api/exceptions.py | 23 +++++++++++++++ src/span_panel_api/factory.py | 50 ++++++++++++++++++++++++++------ tests/test_factory_dispatch.py | 38 ++++++++++++++++++++++-- 3 files changed, 100 insertions(+), 11 deletions(-) diff --git a/src/span_panel_api/exceptions.py b/src/span_panel_api/exceptions.py index 16323a0..765966c 100644 --- a/src/span_panel_api/exceptions.py +++ b/src/span_panel_api/exceptions.py @@ -42,6 +42,29 @@ class SpanPanelStaleDataError(SpanPanelError): """ +class SpanPanelSchemaVersionError(SpanPanelError): + """The panel reports a data-model-version this library cannot interpret. + + Distinct from SpanPanelAdapterMissingError, because the remedy differs. A + missing adapter is a known schema with no installed parser — install or + update the adapter package. This is a schema whose *major cannot even be + determined*, so no adapter can be named. That is a panel this library has + never seen, and the honest response is to say so. + + Absence is not this error: a panel that publishes no data-model-version at + all is speaking the flat schema, which is a real and supported signal. + """ + + def __init__(self, data_model_version: str) -> None: + self.data_model_version = data_model_version + super().__init__( + f"Cannot determine a schema major from data-model-version {data_model_version!r}. " + "Expected MAJOR.MINOR[.PATCH]. Refusing to guess — parsing this panel with the " + "wrong schema would produce plausible but incorrect power and energy values. " + "Please report this value." + ) + + class SpanPanelAdapterMissingError(SpanPanelError): """No installed adapter covers the schema this panel publishes.""" diff --git a/src/span_panel_api/factory.py b/src/span_panel_api/factory.py index 7c2f9aa..903f95e 100644 --- a/src/span_panel_api/factory.py +++ b/src/span_panel_api/factory.py @@ -12,7 +12,7 @@ from .adapters import resolve_adapter from .auth import register_v2 from .detection import detect_api_version -from .exceptions import SpanPanelAuthError +from .exceptions import SpanPanelAuthError, SpanPanelSchemaVersionError from .mqtt.client import SpanMqttClient from .mqtt.models import MqttClientConfig @@ -20,7 +20,11 @@ _V2_CLIENT_NAME = "span-panel-api" -_DMV_PATTERN = re.compile(r"^(\d+)\.\d+(?:\.\d+)?$") +# The canonical form the published spec defines: MAJOR.MINOR[.PATCH]. +_DMV_CANONICAL = re.compile(r"^(\d+)\.\d+(?:\.\d+)?$") +# Tolerant form: a leading integer major, optionally followed by a separator and +# anything at all. Accepts '1', '1.0.3-rc2', '1_0'; rejects 'v1.0', '', 'x'. +_DMV_MAJOR = re.compile(r"^(\d+)(?:[._-].*)?$") def _select_adapter_key(data_model_version: str | None) -> tuple[str, str]: @@ -29,18 +33,42 @@ def _select_adapter_key(data_model_version: str | None) -> tuple[str, str]: Absence is the flat-schema signal — the property was introduced by the same firmware that introduced the parent/child model, so a panel that does not publish it is speaking the flat schema. + + Presence is never read as flat. Falling back to schema_0 for a value we do + not recognise would hand a parent/child panel to the flat parser, which does + not fail — it produces plausible but wrong power and energy figures. A wrong + number in Home Assistant is worse than an error, so anything present and + unreadable raises instead. + + Between those two poles sits a value whose major is unambiguous even though + its full form is not canonical ('1', '1.0-beta'). That is not a guess: the + major is what selects the adapter, and it was read, not assumed. Those + dispatch normally and log the deviation, so a firmware that starts emitting + a new format is visible before it is an outage. + + Raises: + SpanPanelSchemaVersionError: A version is present but no major can be + extracted from it. """ if data_model_version is None: return "schema_0", "data-model-version absent (flat schema)" - match = _DMV_PATTERN.match(data_model_version) - if match is None: - return "schema_0", f"unrecognised data-model-version={data_model_version!r}, assuming flat" + if (match := _DMV_CANONICAL.match(data_model_version)) is not None: + return f"schema_{int(match.group(1))}", f"data-model-version={data_model_version!r}" + + if (match := _DMV_MAJOR.match(data_model_version)) is not None: + _LOGGER.warning( + "data-model-version=%r is not the canonical MAJOR.MINOR[.PATCH] form; " + "dispatching on major %s. Please report this value.", + data_model_version, + match.group(1), + ) + return ( + f"schema_{int(match.group(1))}", + f"data-model-version={data_model_version!r} (non-canonical; major only)", + ) - return ( - f"schema_{int(match.group(1))}", - f"data-model-version={data_model_version!r}", - ) + raise SpanPanelSchemaVersionError(data_model_version) async def create_span_client( @@ -67,6 +95,10 @@ async def create_span_client( or serial_number could not be determined. SpanPanelConnectionError: Cannot reach panel during detection or registration. SpanPanelTimeoutError: Timeout during detection or registration. + SpanPanelSchemaVersionError: The panel reports a data-model-version whose + schema major cannot be determined. + SpanPanelAdapterMissingError: No installed package provides an adapter for + the schema major this panel reports. """ if mqtt_config is None: if passphrase is None: diff --git a/tests/test_factory_dispatch.py b/tests/test_factory_dispatch.py index 05872a0..2a49778 100644 --- a/tests/test_factory_dispatch.py +++ b/tests/test_factory_dispatch.py @@ -7,7 +7,7 @@ from span_panel_api._impl.schema_0 import SchemaZeroAdapter from span_panel_api.adapters import _reset_adapter_cache -from span_panel_api.exceptions import SpanPanelAdapterMissingError +from span_panel_api.exceptions import SpanPanelAdapterMissingError, SpanPanelSchemaVersionError from span_panel_api.factory import _select_adapter_key from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.models import MqttClientConfig @@ -21,13 +21,47 @@ def test_absent_data_model_version_selects_schema_zero() -> None: assert "absent" in reason -@pytest.mark.parametrize("dmv", ["1.0", "1.4", "2.0"]) +@pytest.mark.parametrize("dmv", ["1.0", "1.4", "2.0", "1.0.3", "10.2"]) def test_present_data_model_version_requests_a_numbered_adapter(dmv: str) -> None: key, reason = _select_adapter_key(dmv) assert key == f"schema_{dmv.split('.')[0]}" assert dmv in reason +@pytest.mark.parametrize("dmv", ["1", "1.0-beta", "1.0.3-rc2", "2_0"]) +def test_non_canonical_but_unambiguous_versions_dispatch_on_their_major(dmv: str) -> None: + """The major was read, not assumed, so dispatching on it is not a guess. + + Refusing these would take a panel offline over a formatting difference; the + deviation is logged instead so a new firmware format is visible early. + """ + key, reason = _select_adapter_key(dmv) + assert key == f"schema_{dmv[0]}" + assert "non-canonical" in reason + + +@pytest.mark.parametrize("dmv", ["", "v1.0", "unknown", "beta", "-1", " 1.0"]) +def test_unreadable_data_model_version_raises_instead_of_assuming_flat(dmv: str) -> None: + """The regression this guards: a present-but-unreadable version must never + reach the flat parser. + + Falling back to schema_0 does not fail — it silently produces plausible but + wrong power and energy values in Home Assistant, which is strictly worse + than an error the user can see and report. + """ + with pytest.raises(SpanPanelSchemaVersionError) as exc: + _select_adapter_key(dmv) + + assert exc.value.data_model_version == dmv + + +def test_absence_is_still_a_supported_signal_not_an_error() -> None: + """The flat schema predates the property, so absence must stay non-fatal — + it is the single most common case in the field today.""" + key, _ = _select_adapter_key(None) + assert key == "schema_0" + + def test_missing_adapter_raises_with_the_installed_list() -> None: from span_panel_api.adapters import resolve_adapter From fc209c2bb3ed2400ca305333e5c6788c7ce52b78 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:10:57 -0700 Subject: [PATCH 012/115] feat(adapters): validate entry points before registering them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit discover_adapters stored whatever ep.load() returned without looking at it. A module, function or instance registered where a class belongs passed straight through resolve_adapter and failed later as an opaque TypeError deep inside connect() — the failure mode SpanPanelAdapterMissingError was introduced to prevent. It was also an Any crossing into a dict[str, type[SchemaAdapter]], against the repo's no-Any standard. Validation is a TypeGuard, so the Any from ep.load() is narrowed by a real runtime check rather than assigned unexamined. The required-member list is derived from SchemaAdapter itself rather than restated, so adding a method to the protocol automatically makes it required of every adapter package; issubclass is unavailable because the protocol has data members and runtime_checkable rejects issubclass() for those. The check is presence-only and deliberately so — a Protocol cannot express signatures at runtime, so wrong arity still surfaces at call time. It catches the failure that actually happens, which is a misdirected entry point, and converts it to a named logged skip. Skipped, never raised: one broken third-party adapter must not take down a panel whose own adapter is installed and fine. Unreachable while schema_0 is the only registered adapter; live as soon as a second one ships. --- src/span_panel_api/adapters.py | 55 +++++++++++++++++--- tests/test_adapters_discovery.py | 89 ++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/src/span_panel_api/adapters.py b/src/span_panel_api/adapters.py index 4f0c70b..d4f92e9 100644 --- a/src/span_panel_api/adapters.py +++ b/src/span_panel_api/adapters.py @@ -8,17 +8,26 @@ from importlib.metadata import entry_points import logging -from typing import TYPE_CHECKING +from typing import TypeGuard from span_panel_api.exceptions import SpanPanelAdapterMissingError - -if TYPE_CHECKING: - from span_panel_api.protocol import SchemaAdapter +from span_panel_api.protocol import SchemaAdapter _LOGGER = logging.getLogger(__name__) _ENTRY_POINT_GROUP = "span_panel_api.schema_adapters" _REGISTRY: dict[str, type[SchemaAdapter]] | None = None +# Derived from the protocol rather than restated, so the check cannot drift out +# of sync with the contract it enforces — adding a method to SchemaAdapter +# automatically makes it required of every adapter package. +# +# `issubclass` is not available here: SchemaAdapter has non-method members, and +# runtime_checkable protocols with data attributes reject issubclass() outright. +_REQUIRED_MEMBERS: tuple[str, ...] = ( + *sorted(SchemaAdapter.__annotations__), + *sorted(name for name, value in vars(SchemaAdapter).items() if callable(value) and not name.startswith("_")), +) + # The adapter key for panels that publish no data-model-version. This is a # bootstrap-level fact — Tier 1 dispatch reads absence as "flat schema" — not an # import of the flat adapter. The bootstrap knows the *name*; whether anything @@ -26,8 +35,37 @@ DEFAULT_ADAPTER_KEY = "schema_0" +def _is_adapter_class(loaded: object) -> TypeGuard[type[SchemaAdapter]]: + """Narrow an entry point's loaded object to an adapter class. + + A TypeGuard rather than a bare bool: `ep.load()` returns `Any`, and this is + the boundary where that `Any` has to become a checked `type[SchemaAdapter]` + rather than being assigned into the registry unexamined. + + Deliberately checks member *presence* only. A Protocol cannot express + signatures at runtime, so an adapter with the right names and the wrong + arity still gets through and fails at call time. The check is worth having + anyway: it catches the failure that actually happens — a module, function or + instance registered where a class belongs — and turns it into a named, + logged skip instead of an opaque TypeError deep inside connect(). + """ + return isinstance(loaded, type) and all(hasattr(loaded, member) for member in _REQUIRED_MEMBERS) + + +def _describe_defect(loaded: object) -> str: + """Explain why `loaded` failed _is_adapter_class. Only called on the error path.""" + if not isinstance(loaded, type): + return f"expected a class, got {type(loaded).__name__}" + missing = [member for member in _REQUIRED_MEMBERS if not hasattr(loaded, member)] + return f"{loaded.__name__} does not implement SchemaAdapter (missing: {', '.join(missing)})" + + def discover_adapters() -> dict[str, type[SchemaAdapter]]: - """Load and cache every adapter class registered under the entry-point group.""" + """Load and cache every adapter class registered under the entry-point group. + + A bad entry point is skipped with a logged reason, never raised: one broken + third-party adapter must not take down a panel whose own adapter is fine. + """ global _REGISTRY # pylint: disable=global-statement # process-lifetime cache by design if _REGISTRY is None: registry: dict[str, type[SchemaAdapter]] = {} @@ -36,9 +74,14 @@ def discover_adapters() -> dict[str, type[SchemaAdapter]]: _LOGGER.warning("Duplicate schema adapter entry point %r; keeping the first found", ep.name) continue try: - registry[ep.name] = ep.load() + loaded: object = ep.load() except Exception: # pylint: disable=broad-exception-caught _LOGGER.exception("Failed to load schema adapter entry point %r", ep.name) + continue + if not _is_adapter_class(loaded): + _LOGGER.error("Ignoring schema adapter entry point %r: %s", ep.name, _describe_defect(loaded)) + continue + registry[ep.name] = loaded _REGISTRY = registry return _REGISTRY diff --git a/tests/test_adapters_discovery.py b/tests/test_adapters_discovery.py index 8d574ae..627cc4e 100644 --- a/tests/test_adapters_discovery.py +++ b/tests/test_adapters_discovery.py @@ -85,3 +85,92 @@ def test_resolve_adapter_names_what_is_installed() -> None: assert exc.value.needed == "schema_9" assert DEFAULT_ADAPTER_KEY in exc.value.available + + +# --------------------------------------------------------------------------- +# Entry-point validation — a bad adapter package must not become an opaque +# TypeError deep inside connect() +# --------------------------------------------------------------------------- + + +class _FakeEntryPoint: + def __init__(self, name: str, value: object) -> None: + self.name = name + self._value = value + + def load(self) -> object: + return self._value + + +def _discover_with(*eps: _FakeEntryPoint) -> dict[str, object]: + _reset_adapter_cache() + with patch("span_panel_api.adapters.entry_points", return_value=list(eps)): + return dict(discover_adapters()) + + +def test_required_members_are_derived_from_the_protocol() -> None: + """The check must not restate the contract — a method added to SchemaAdapter + becomes required of every adapter without anyone remembering to update a list.""" + from span_panel_api.adapters import _REQUIRED_MEMBERS + from span_panel_api.protocol import SchemaAdapter + + assert set(SchemaAdapter.__annotations__) <= set(_REQUIRED_MEMBERS) + assert "topics_to_subscribe" in _REQUIRED_MEMBERS + assert "build_snapshot" in _REQUIRED_MEMBERS + # Dunders are excluded: presence tells us nothing, every object has them. + assert not [member for member in _REQUIRED_MEMBERS if member.startswith("_")] + + +@pytest.mark.parametrize( + ("label", "value"), + [ + ("a module", pytest), + ("a function", lambda serial, size: None), + ("an instance rather than a class", object()), + ("a string", "span_panel_api_schema_0:SchemaZeroAdapter"), + ], +) +def test_non_class_entry_points_are_skipped_not_registered(label: str, value: object) -> None: + """The failure that actually happens: an entry point pointing at the wrong + kind of object. Phase 0 stored it and blew up later inside connect().""" + assert _discover_with(_FakeEntryPoint("schema_9", value)) == {}, label + + +def test_a_class_missing_protocol_members_is_skipped() -> None: + class NotAnAdapter: + schema_major = "schema_9" + + assert _discover_with(_FakeEntryPoint("schema_9", NotAnAdapter)) == {} + + +def test_a_conforming_class_is_registered() -> None: + from span_panel_api._impl.schema_0 import SchemaZeroAdapter + + registry = _discover_with(_FakeEntryPoint("schema_0", SchemaZeroAdapter)) + + assert registry == {"schema_0": SchemaZeroAdapter} + + +def test_one_bad_adapter_does_not_hide_the_good_ones() -> None: + """A broken third-party adapter must not take down a panel whose own adapter + is installed and fine.""" + from span_panel_api._impl.schema_0 import SchemaZeroAdapter + + registry = _discover_with( + _FakeEntryPoint("schema_9", "not a class"), + _FakeEntryPoint("schema_0", SchemaZeroAdapter), + ) + + assert registry == {"schema_0": SchemaZeroAdapter} + + +def test_an_entry_point_that_raises_on_load_is_skipped() -> None: + from span_panel_api._impl.schema_0 import SchemaZeroAdapter + + class Exploding(_FakeEntryPoint): + def load(self) -> object: + raise ImportError("adapter package is half-installed") + + registry = _discover_with(Exploding("schema_9", None), _FakeEntryPoint("schema_0", SchemaZeroAdapter)) + + assert registry == {"schema_0": SchemaZeroAdapter} From 6308acdd04689912ed4bb35458b333d288cc4d94 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:15:36 -0700 Subject: [PATCH 013/115] chore: clear the Phase 1 small items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostics move into the SpanMqttClient constructor. The factory patched _data_model_version and _schema_dispatch_reason onto private state after construction, which needed two protected-access disables and left a window where a connected client could report a selected adapter alongside schema_dispatch_reason='not dispatched'. They are now true from the moment the object exists; constructing directly still describes exactly that. SUPPORTS_DATA_MODEL_VERSIONS had two independent literals — the class attribute the protocol requires and a module constant beside it — with nothing asserting they agreed. The module now re-exports the class attribute, so the class is the single source. A drift here would have been invisible until a panel reported a version the adapter falsely claimed. Exports SpanPanelAdapterMissingError and SpanPanelSchemaVersionError. Both are errors a user actually sees when their panel outruns their install, so catching them should not require reaching into a private module. Deletes DEVICE_TOPIC_FMT, STATE_TOPIC_FMT, DESCRIPTION_TOPIC_FMT and PROPERTY_TOPIC_FMT (dead before Phase 0 relocated them; the adapter reads through one wildcard subscription and writes through the set pattern) and TYPE_PCS (a real schema type this library does not consume). Documents the two type namespaces in const.py, which are easy to conflate: the schema's "types" block declares properties per type, while a node's $description carries the type string actually on the wire, and they are not the same set. TYPE_LUGS_UPSTREAM/DOWNSTREAM are real wire types confirmed against a live panel in 1eef0dc but are absent from the schema, which declares only the base lugs type — so each needs a _LUGS_FALLBACK alias or property metadata silently comes back empty. Corrects the stale "kept in sync with homie.py" reference to consumer.py. --- src/span_panel_api/__init__.py | 4 ++ src/span_panel_api/_impl/schema_0/__init__.py | 8 ++-- src/span_panel_api/_impl/schema_0/const.py | 39 ++++++++++++++----- .../_impl/schema_0/field_metadata.py | 3 +- src/span_panel_api/factory.py | 12 ++++-- src/span_panel_api/mqtt/client.py | 11 ++++-- tests/test_factory_dispatch.py | 9 +++-- tests/test_public_api_unchanged.py | 2 + 8 files changed, 65 insertions(+), 23 deletions(-) diff --git a/src/span_panel_api/__init__.py b/src/span_panel_api/__init__.py index fb8af21..04994d0 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -18,10 +18,12 @@ ) from .detection import DetectionResult, detect_api_version from .exceptions import ( + SpanPanelAdapterMissingError, SpanPanelAPIError, SpanPanelAuthError, SpanPanelConnectionError, SpanPanelError, + SpanPanelSchemaVersionError, SpanPanelServerError, SpanPanelStaleDataError, SpanPanelTimeoutError, @@ -104,7 +106,9 @@ "validate_solar_tabs", # Exceptions "SpanPanelAPIError", + "SpanPanelAdapterMissingError", "SpanPanelAuthError", + "SpanPanelSchemaVersionError", "SpanPanelConnectionError", "SpanPanelError", "SpanPanelServerError", diff --git a/src/span_panel_api/_impl/schema_0/__init__.py b/src/span_panel_api/_impl/schema_0/__init__.py index a5314d1..e5c10b5 100644 --- a/src/span_panel_api/_impl/schema_0/__init__.py +++ b/src/span_panel_api/_impl/schema_0/__init__.py @@ -2,8 +2,10 @@ from span_panel_api._impl.schema_0.adapter import SchemaZeroAdapter -# Inclusive lower bound, exclusive upper bound. The flat schema publishes no -# data-model-version, so it is treated as the synthetic version 0 range. -SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = (">=0", "<1.0") +# Re-exported from the adapter rather than restated. The protocol requires the +# range as a class attribute, so the class is the source of truth; a second +# literal here would be free to drift, and nothing would notice until a panel +# reported a version this adapter claims — falsely — to support. +SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = SchemaZeroAdapter.SUPPORTS_DATA_MODEL_VERSIONS __all__ = ["SUPPORTS_DATA_MODEL_VERSIONS", "SchemaZeroAdapter"] diff --git a/src/span_panel_api/_impl/schema_0/const.py b/src/span_panel_api/_impl/schema_0/const.py index 74b17e5..6e85d5f 100644 --- a/src/span_panel_api/_impl/schema_0/const.py +++ b/src/span_panel_api/_impl/schema_0/const.py @@ -5,26 +5,47 @@ HOMIE_DOMAIN = "ebus" TOPIC_PREFIX = f"{HOMIE_DOMAIN}/{HOMIE_VERSION}" -# Topic patterns (serial_number substituted at runtime) -DEVICE_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}" -STATE_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/$state" -DESCRIPTION_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/$description" -PROPERTY_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/{{node}}/{{prop}}" +# Topic patterns (serial_number substituted at runtime). +# The adapter subscribes with the wildcard and publishes with the set pattern; +# per-topic read formats are not needed because every message arrives through +# the one wildcard subscription. PROPERTY_SET_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/{{node}}/{{prop}}/set" WILDCARD_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/#" -# Homie type strings from schema +# --------------------------------------------------------------------------- +# Homie type strings. +# +# Two namespaces that are easy to conflate and are NOT the same set: +# +# * the `types` block of GET /api/v2/homie/schema, which declares the +# properties, units and datatypes available to a type; and +# * the `type` string a node actually carries in its $description on the wire. +# +# Every constant below is a node type observed on the wire. The ones in the +# first group are also declared in the schema, so metadata lookup finds them +# directly. See tests/test_schema_provenance.py, which asserts that. +# --------------------------------------------------------------------------- TYPE_CORE = "energy.ebus.device.distribution-enclosure.core" TYPE_LUGS = "energy.ebus.device.lugs" -TYPE_LUGS_UPSTREAM = "energy.ebus.device.lugs.upstream" -TYPE_LUGS_DOWNSTREAM = "energy.ebus.device.lugs.downstream" TYPE_CIRCUIT = "energy.ebus.device.circuit" TYPE_BESS = "energy.ebus.device.bess" TYPE_PV = "energy.ebus.device.pv" TYPE_EVSE = "energy.ebus.device.evse" -TYPE_PCS = "energy.ebus.device.pcs" TYPE_POWER_FLOWS = "energy.ebus.device.power-flows" +# Wire-only subtypes: real node types published by real firmware (confirmed +# against a live panel in 1eef0dc), but NOT declared in the schema's `types` +# block, which carries only the base `energy.ebus.device.lugs`. Firmware uses +# one convention or the other — typed nodes, or generic nodes plus a +# `direction` property — and _find_lugs_node handles both. +# +# Because the schema does not declare them, every one of these needs an entry +# in field_metadata._LUGS_FALLBACK mapping it to a declared type, or property +# metadata silently comes back empty for those nodes. The provenance test +# asserts that pairing rather than trusting it. +TYPE_LUGS_UPSTREAM = "energy.ebus.device.lugs.upstream" +TYPE_LUGS_DOWNSTREAM = "energy.ebus.device.lugs.downstream" + # Lugs direction values LUGS_UPSTREAM = "UPSTREAM" LUGS_DOWNSTREAM = "DOWNSTREAM" diff --git a/src/span_panel_api/_impl/schema_0/field_metadata.py b/src/span_panel_api/_impl/schema_0/field_metadata.py index ab83d68..6f5b828 100644 --- a/src/span_panel_api/_impl/schema_0/field_metadata.py +++ b/src/span_panel_api/_impl/schema_0/field_metadata.py @@ -33,7 +33,8 @@ # # This encodes the library's internal knowledge of how _build_snapshot() # maps Homie properties to snapshot dataclass fields. The mapping must be -# kept in sync with homie.py. +# kept in sync with consumer.py (which held this class as homie.py before +# the Phase 0 relocation). # --------------------------------------------------------------------------- _PROPERTY_FIELD_MAP: tuple[tuple[str, str, str], ...] = ( diff --git a/src/span_panel_api/factory.py b/src/span_panel_api/factory.py index 903f95e..2f7e125 100644 --- a/src/span_panel_api/factory.py +++ b/src/span_panel_api/factory.py @@ -131,8 +131,14 @@ async def create_span_client( adapter_key, dispatch_reason = _select_adapter_key(data_model_version) adapter_cls = resolve_adapter(adapter_key, dispatch_reason) - client = SpanMqttClient(host, serial_number, mqtt_config, panel_http_port=port, adapter_factory=adapter_cls) - client._data_model_version = data_model_version # pylint: disable=protected-access - client._schema_dispatch_reason = dispatch_reason # pylint: disable=protected-access + client = SpanMqttClient( + host, + serial_number, + mqtt_config, + panel_http_port=port, + adapter_factory=adapter_cls, + data_model_version=data_model_version, + schema_dispatch_reason=dispatch_reason, + ) await client.connect() return client diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 142cc2d..9e418eb 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -44,6 +44,8 @@ def __init__( snapshot_interval: float = 1.0, panel_http_port: int = 80, adapter_factory: Callable[[str, int], SchemaAdapter] | None = None, + data_model_version: str | None = None, + schema_dispatch_reason: str | None = None, ) -> None: self._host = host self._serial_number = serial_number @@ -69,10 +71,11 @@ def __init__( # Homie accumulator with the same panel size after a transport-level # rebuild. Schema cannot change within a session, so caching is safe. self._panel_size: int | None = None - # Diagnostics — the factory overwrites these after adapter selection. - # Defaults describe a client built directly (bypassing create_span_client). - self._data_model_version: str | None = None - self._schema_dispatch_reason: str = "not dispatched" + # Diagnostics, passed in by create_span_client so they are true from the + # first moment the object exists. Constructing directly leaves them + # describing exactly that: a client that never went through dispatch. + self._data_model_version = data_model_version + self._schema_dispatch_reason = schema_dispatch_reason or "not dispatched" def _build_adapter(self, panel_size: int) -> SchemaAdapter: """Construct the parser for this session. diff --git a/tests/test_factory_dispatch.py b/tests/test_factory_dispatch.py index 2a49778..c50fd80 100644 --- a/tests/test_factory_dispatch.py +++ b/tests/test_factory_dispatch.py @@ -102,9 +102,12 @@ async def test_create_span_client_wires_schema_zero_adapter_and_diagnostics() -> _, kwargs = mock_cls.call_args assert kwargs["adapter_factory"] is SchemaZeroAdapter mock_client.connect.assert_awaited_once() - # Diagnostics were assigned directly on the instance ahead of connect(). - assert mock_client._data_model_version is None # pylint: disable=protected-access - assert "absent" in mock_client._schema_dispatch_reason # pylint: disable=protected-access + # Diagnostics travel through the constructor, so they are true before + # connect() rather than patched onto private state afterwards. There is no + # longer a window where a connected client reports a selected adapter next + # to schema_dispatch_reason='not dispatched'. + assert kwargs["data_model_version"] is None + assert "absent" in kwargs["schema_dispatch_reason"] # --------------------------------------------------------------------------- diff --git a/tests/test_public_api_unchanged.py b/tests/test_public_api_unchanged.py index 19503d7..3375f6b 100644 --- a/tests/test_public_api_unchanged.py +++ b/tests/test_public_api_unchanged.py @@ -62,7 +62,9 @@ "validate_solar_tabs", # Exceptions "SpanPanelAPIError", + "SpanPanelAdapterMissingError", "SpanPanelAuthError", + "SpanPanelSchemaVersionError", "SpanPanelConnectionError", "SpanPanelError", "SpanPanelServerError", From 48aef8a10a3f39f9b2a584009ab9f3d0e91146a2 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:28:55 -0700 Subject: [PATCH 014/115] feat!: ship schema_0 as its own distribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: span-panel-api no longer contains a parser. Installing it alone gives a client that connects and then raises SpanPanelAdapterMissingError. Flat-schema panels need span-panel-api-schema-0 installed alongside it. This is what Phase 0's protocol seam was for. Phase 0 proved the transport could delegate all parsing to a SchemaAdapter; it did not prove the parser could be absent, because the bootstrap still imported _impl/schema_0 in three places. Those were severed in the preceding commits, so the code can now actually move. Layout is a uv workspace: the bootstrap stays at src/span_panel_api, the adapter becomes packages/schema-0 publishing span-panel-api-schema-0 with its own version and README. The entry-point block moves from the root pyproject to the adapter's — that single move is what makes the bootstrap adapter-less; everything else is import rewriting. git mv keeps rename detection, so the diff reads as a move (const.py is byte-identical). Adds scripts/verify_adapterless_install.py and a CI step that runs it against a venv holding only the bootstrap wheel. This cannot be a unit test: the thing under test is installed distribution metadata — which wheel carries the entry point, and whether the import graph reaches a parser — and a test in the development workspace always has the adapter importable, so it can never observe the failure it would be guarding. Verified locally end to end: bootstrap alone imports and fails by name; adding the adapter wheel makes discovery, construction and topic generation resolve. Two tool configs had to learn the repo has two source roots: - vulture scanned only src/span_panel_api, so moving the adapter out made its protocol parameters look unused. It now scans both trees. - pylint's wrong-import-order is disabled. pylint offers known-standard-library and known-third-party but no known-first-party, so it cannot be told that span_panel_api_schema_0 is first-party and disagreed with ruff on every adapter module. ruff's isort enforces the same rule and can be told the truth via known-first-party, so it becomes the single authority. Versions go to 3.0.0b1 / 1.0.0b1 because the adapter declares a dependency on the bootstrap and needs a real version to resolve against. --- .github/workflows/ci.yml | 29 +++++-- .pre-commit-config.yaml | 2 +- packages/schema-0/README.md | 32 ++++++++ packages/schema-0/pyproject.toml | 35 +++++++++ .../src/span_panel_api_schema_0}/__init__.py | 2 +- .../span_panel_api_schema_0}/accumulator.py | 2 +- .../src/span_panel_api_schema_0}/adapter.py | 8 +- .../src/span_panel_api_schema_0}/const.py | 0 .../src/span_panel_api_schema_0}/consumer.py | 18 ++--- .../field_metadata.py | 4 +- pyproject.toml | 36 ++++++++- scripts/verify_adapterless_install.py | 77 +++++++++++++++++++ src/span_panel_api/_impl/__init__.py | 1 - src/span_panel_api/schema_drift.py | 2 +- tests/conftest.py | 2 +- tests/test_accumulator.py | 4 +- tests/test_adapters_discovery.py | 6 +- tests/test_auth_and_homie_helpers.py | 4 +- tests/test_factory_dispatch.py | 2 +- tests/test_field_metadata.py | 2 +- tests/test_mqtt_client_connection.py | 6 +- tests/test_mqtt_homie.py | 14 ++-- tests/test_protocol_conformance.py | 2 +- tests/test_schema_zero_adapter.py | 2 +- uv.lock | 25 +++++- 25 files changed, 260 insertions(+), 57 deletions(-) create mode 100644 packages/schema-0/README.md create mode 100644 packages/schema-0/pyproject.toml rename {src/span_panel_api/_impl/schema_0 => packages/schema-0/src/span_panel_api_schema_0}/__init__.py (88%) rename {src/span_panel_api/_impl/schema_0 => packages/schema-0/src/span_panel_api_schema_0}/accumulator.py (99%) rename {src/span_panel_api/_impl/schema_0 => packages/schema-0/src/span_panel_api_schema_0}/adapter.py (88%) rename {src/span_panel_api/_impl/schema_0 => packages/schema-0/src/span_panel_api_schema_0}/const.py (100%) rename {src/span_panel_api/_impl/schema_0 => packages/schema-0/src/span_panel_api_schema_0}/consumer.py (99%) rename {src/span_panel_api/_impl/schema_0 => packages/schema-0/src/span_panel_api_schema_0}/field_metadata.py (99%) create mode 100644 scripts/verify_adapterless_install.py delete mode 100644 src/span_panel_api/_impl/__init__.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69cfd2f..23b0983 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: enable-cache: true - name: Install dependencies - run: uv sync + run: uv sync --all-packages - name: Run pre-commit hooks run: | @@ -36,7 +36,10 @@ jobs: - name: Run tests with pytest run: | - uv run pytest tests/ -v --cov=src/span_panel_api --cov-report=xml --cov-report=term-missing + uv run pytest tests/ -v \ + --cov=src/span_panel_api \ + --cov=packages/schema-0/src/span_panel_api_schema_0 \ + --cov-report=xml --cov-report=term-missing @@ -57,11 +60,11 @@ jobs: enable-cache: true - name: Install dependencies - run: uv sync + run: uv sync --all-packages - name: Run Bandit security scan run: | - uv run bandit -r src/ -f json -o bandit-report.json || true + uv run bandit -r src/ packages/ -f json -o bandit-report.json || true - name: Upload Bandit scan results uses: actions/upload-artifact@v7 @@ -86,14 +89,24 @@ jobs: enable-cache: true - name: Install dependencies - run: uv sync + run: uv sync --all-packages - - name: Build package - run: uv build + - name: Build packages + run: uv build --all-packages - - name: Check package + - name: Check packages run: uv run twine check dist/* + # The configuration entry-point discovery exists to support, and the one + # nothing else in CI exercises: the bootstrap wheel installed with no + # adapter present. It must import, and it must fail by name rather than + # with ModuleNotFoundError. + - name: Verify the bootstrap installs without an adapter + run: | + uv venv /tmp/bootstrap-only + VIRTUAL_ENV=/tmp/bootstrap-only uv pip install dist/span_panel_api-*.whl + VIRTUAL_ENV=/tmp/bootstrap-only uv run --no-project python scripts/verify_adapterless_install.py + - name: Upload build artifacts uses: actions/upload-artifact@v7 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7ae6a9a..2b25c4c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -108,7 +108,7 @@ repos: hooks: - id: vulture name: vulture - entry: bash -c 'uv run vulture src/span_panel_api/ --min-confidence 80' + entry: bash -c 'uv run vulture src/span_panel_api/ packages/schema-0/src/span_panel_api_schema_0/ --min-confidence 80' language: system types: [python] pass_filenames: false diff --git a/packages/schema-0/README.md b/packages/schema-0/README.md new file mode 100644 index 0000000..9a8abc3 --- /dev/null +++ b/packages/schema-0/README.md @@ -0,0 +1,32 @@ +# span-panel-api-schema-0 + +The **flat-schema** parser for [`span-panel-api`](https://github.com/SpanPanel/span-panel-api): the single-device Homie model published by SPAN firmware `r202603` through `r202627`, which carries no `data-model-version`. + +## Why this is a separate distribution + +`span-panel-api` is a transport and a dispatcher. It knows how to connect to a panel's MQTT broker, route messages, and choose a parser — but it contains no parsing code and no Homie type strings. Each wire format ships as its own distribution and +registers itself under the `span_panel_api.schema_adapters` entry-point group. + +That split exists because the two halves break on different axes. The wire format changes when SPAN ships firmware; the library API changes when we do. Separate distributions let each carry its own version, so a consumer can pin them independently and add +support for a new panel schema by installing a package rather than by upgrading the transport. + +## Installation + +```console +pip install span-panel-api span-panel-api-schema-0 +``` + +Installing this package is what makes flat-schema panels work. `span-panel-api` on its own will connect and then raise `SpanPanelAdapterMissingError` naming the adapter it could not find. + +A consumer that wants to support panels on either schema installs both adapters: + +```console +pip install span-panel-api span-panel-api-schema-0 span-panel-api-schema-1 +``` + +Dispatch happens at runtime, per panel, from the `data-model-version` the panel reports. + +## Retirement + +SPAN retires the flat schema in the same release that introduces the parent/child model (`r202633`, fleet rollout projected for early September 2026). When the fleet has moved, consumers drop this package from their requirements. Published versions stay on +PyPI for anyone still running older firmware. diff --git a/packages/schema-0/pyproject.toml b/packages/schema-0/pyproject.toml new file mode 100644 index 0000000..b7d6a58 --- /dev/null +++ b/packages/schema-0/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "span-panel-api-schema-0" +version = "1.0.0b1" +description = "Flat-schema (data-model-version absent) parser for span-panel-api" +authors = [ + {name = "SpanPanel"} +] +readme = "README.md" +license = "MIT" +requires-python = ">=3.10,<4.0" +dependencies = [ + "span-panel-api>=3.0.0b1,<4.0", +] + +[project.urls] +Homepage = "https://github.com/SpanPanel/span-panel-api" +Issues = "https://github.com/SpanPanel/span-panel-api/issues" + +# The whole point of this distribution. The bootstrap finds this adapter by +# discovering the group, never by importing this package. +[project.entry-points."span_panel_api.schema_adapters"] +schema_0 = "span_panel_api_schema_0:SchemaZeroAdapter" + +# Resolve the bootstrap from the workspace when developing here. Published +# wheels are unaffected: this table is uv-only metadata and the dependency +# above is what a consumer installing from PyPI sees. +[tool.uv.sources] +span-panel-api = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/span_panel_api_schema_0"] diff --git a/src/span_panel_api/_impl/schema_0/__init__.py b/packages/schema-0/src/span_panel_api_schema_0/__init__.py similarity index 88% rename from src/span_panel_api/_impl/schema_0/__init__.py rename to packages/schema-0/src/span_panel_api_schema_0/__init__.py index e5c10b5..6b7b783 100644 --- a/src/span_panel_api/_impl/schema_0/__init__.py +++ b/packages/schema-0/src/span_panel_api_schema_0/__init__.py @@ -1,6 +1,6 @@ """Flat-schema adapter package (data-model-version absent).""" -from span_panel_api._impl.schema_0.adapter import SchemaZeroAdapter +from span_panel_api_schema_0.adapter import SchemaZeroAdapter # Re-exported from the adapter rather than restated. The protocol requires the # range as a class attribute, so the class is the source of truth; a second diff --git a/src/span_panel_api/_impl/schema_0/accumulator.py b/packages/schema-0/src/span_panel_api_schema_0/accumulator.py similarity index 99% rename from src/span_panel_api/_impl/schema_0/accumulator.py rename to packages/schema-0/src/span_panel_api_schema_0/accumulator.py index 8b82e7f..eeae58f 100644 --- a/src/span_panel_api/_impl/schema_0/accumulator.py +++ b/packages/schema-0/src/span_panel_api_schema_0/accumulator.py @@ -13,8 +13,8 @@ import logging import time -from span_panel_api._impl.schema_0.const import TOPIC_PREFIX from span_panel_api.mqtt.const import HOMIE_STATE_DISCONNECTED, HOMIE_STATE_LOST, HOMIE_STATE_READY +from span_panel_api_schema_0.const import TOPIC_PREFIX _LOGGER = logging.getLogger(__name__) diff --git a/src/span_panel_api/_impl/schema_0/adapter.py b/packages/schema-0/src/span_panel_api_schema_0/adapter.py similarity index 88% rename from src/span_panel_api/_impl/schema_0/adapter.py rename to packages/schema-0/src/span_panel_api_schema_0/adapter.py index e03a3c1..d3dce43 100644 --- a/src/span_panel_api/_impl/schema_0/adapter.py +++ b/packages/schema-0/src/span_panel_api_schema_0/adapter.py @@ -10,10 +10,10 @@ from collections.abc import Callable from typing import TYPE_CHECKING -from span_panel_api._impl.schema_0.accumulator import HomiePropertyAccumulator -from span_panel_api._impl.schema_0.const import PROPERTY_SET_TOPIC_FMT, TYPE_CORE, WILDCARD_TOPIC_FMT -from span_panel_api._impl.schema_0.consumer import HomieDeviceConsumer -from span_panel_api._impl.schema_0.field_metadata import build_field_metadata +from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator +from span_panel_api_schema_0.const import PROPERTY_SET_TOPIC_FMT, TYPE_CORE, WILDCARD_TOPIC_FMT +from span_panel_api_schema_0.consumer import HomieDeviceConsumer +from span_panel_api_schema_0.field_metadata import build_field_metadata if TYPE_CHECKING: from span_panel_api.models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot diff --git a/src/span_panel_api/_impl/schema_0/const.py b/packages/schema-0/src/span_panel_api_schema_0/const.py similarity index 100% rename from src/span_panel_api/_impl/schema_0/const.py rename to packages/schema-0/src/span_panel_api_schema_0/const.py diff --git a/src/span_panel_api/_impl/schema_0/consumer.py b/packages/schema-0/src/span_panel_api_schema_0/consumer.py similarity index 99% rename from src/span_panel_api/_impl/schema_0/consumer.py rename to packages/schema-0/src/span_panel_api_schema_0/consumer.py index ba7a29c..ef716f4 100644 --- a/src/span_panel_api/_impl/schema_0/consumer.py +++ b/packages/schema-0/src/span_panel_api_schema_0/consumer.py @@ -12,8 +12,15 @@ import time from typing import ClassVar -from span_panel_api._impl.schema_0.accumulator import HomiePropertyAccumulator -from span_panel_api._impl.schema_0.const import ( +from span_panel_api.models import ( + SpanBatterySnapshot, + SpanCircuitSnapshot, + SpanEvseSnapshot, + SpanPanelSnapshot, + SpanPVSnapshot, +) +from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator +from span_panel_api_schema_0.const import ( LUGS_DOWNSTREAM, LUGS_UPSTREAM, TYPE_BESS, @@ -27,13 +34,6 @@ TYPE_PV, normalize_circuit_id, ) -from span_panel_api.models import ( - SpanBatterySnapshot, - SpanCircuitSnapshot, - SpanEvseSnapshot, - SpanPanelSnapshot, - SpanPVSnapshot, -) _LOGGER = logging.getLogger(__name__) diff --git a/src/span_panel_api/_impl/schema_0/field_metadata.py b/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py similarity index 99% rename from src/span_panel_api/_impl/schema_0/field_metadata.py rename to packages/schema-0/src/span_panel_api_schema_0/field_metadata.py index 6f5b828..6da5388 100644 --- a/src/span_panel_api/_impl/schema_0/field_metadata.py +++ b/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py @@ -15,7 +15,8 @@ from __future__ import annotations -from span_panel_api._impl.schema_0.const import ( +from span_panel_api.models import FieldMetadata, HomieSchemaTypes +from span_panel_api_schema_0.const import ( TYPE_BESS, TYPE_CIRCUIT, TYPE_CORE, @@ -26,7 +27,6 @@ TYPE_POWER_FLOWS, TYPE_PV, ) -from span_panel_api.models import FieldMetadata, HomieSchemaTypes # --------------------------------------------------------------------------- # Static mapping: (node_type, property_id) → snapshot field path diff --git a/pyproject.toml b/pyproject.toml index 715f110..7cf6846 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "2.6.4" +version = "3.0.0b1" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} @@ -22,11 +22,18 @@ Issues = "https://github.com/SpanPanel/span-panel-api/issues" [project.scripts] format-markdown = "scripts.format_markdown:main" -[project.entry-points."span_panel_api.schema_adapters"] -schema_0 = "span_panel_api._impl.schema_0:SchemaZeroAdapter" +# No [project.entry-points."span_panel_api.schema_adapters"] block here, and that +# absence is the point of Phase 1: this distribution registers no adapter and +# imports none. Adapters are separate distributions that register themselves — +# see packages/schema-0. Adding a block here would silently re-couple the +# bootstrap to a parser and undo the split. [dependency-groups] dev = [ + # The adapter is a dev dependency, never a runtime one: the bootstrap must + # remain installable without it. It is here so the test suite exercises the + # two distributions together, which is the configuration users will run. + "span-panel-api-schema-0", "pytest>=9.0.2", "pytest-asyncio>=1.3.0", "pytest-cov", @@ -47,6 +54,16 @@ dev = [ requires = ["hatchling"] build-backend = "hatchling.build" +# One repo, independent distributions. The adapter is a workspace member so the +# test suite runs against both halves together, while `uv build` in each +# directory still produces a distribution that can be installed on its own — +# which is what the adapter-less install test depends on. +[tool.uv.workspace] +members = ["packages/*"] + +[tool.uv.sources] +span-panel-api-schema-0 = { workspace = true } + [tool.hatch.build.targets.wheel] packages = ["src/span_panel_api", "scripts"] @@ -97,6 +114,10 @@ ignore = [ force-sort-within-sections = true combine-as-imports = true split-on-trailing-comma = false +# Both distributions in this workspace are first-party. Stated explicitly +# because the repo now has two source roots, and inference from a single `src/` +# would classify the adapter package as third-party. +known-first-party = ["span_panel_api", "span_panel_api_schema_0"] [tool.mypy] python_version = "3.13" @@ -126,7 +147,7 @@ ignore_missing_imports = true [tool.coverage.run] data_file = ".local_coverage_data" -source = ["src/span_panel_api"] +source = ["src/span_panel_api", "packages/schema-0/src/span_panel_api_schema_0"] omit = [ "tests/*", "*/tests/*", @@ -172,6 +193,13 @@ ignore-paths = [ [tool.pylint.messages_control] disable = [ + # Import order is enforced by ruff's isort rules (lint select "I"), which + # knows both workspace source roots via known-first-party. pylint has no + # equivalent setting — only known-standard-library and known-third-party — + # so it classifies span_panel_api_schema_0 as third-party and disagrees with + # ruff on every adapter module. One authority for import order; ruff is the + # one that can be told the truth about this layout. + "wrong-import-order", "missing-module-docstring", "missing-class-docstring", "missing-function-docstring", diff --git a/scripts/verify_adapterless_install.py b/scripts/verify_adapterless_install.py new file mode 100644 index 0000000..f3cff98 --- /dev/null +++ b/scripts/verify_adapterless_install.py @@ -0,0 +1,77 @@ +"""Verify the bootstrap distribution works with no adapter installed. + +This is the acceptance check for the Phase 1 packaging split, and it cannot be +written as a unit test: the thing under test *is* the installed distribution +metadata — which wheel carries the entry point, and whether the bootstrap's +import graph reaches a parser. A test running in the development workspace +always has the adapter importable, so it can never observe the failure this +guards against. + +Run it in a virtualenv that has ONLY span-panel-api installed: + + uv venv /tmp/bootstrap-only + VIRTUAL_ENV=/tmp/bootstrap-only uv pip install dist/span_panel_api-*.whl + VIRTUAL_ENV=/tmp/bootstrap-only uv run --no-project \ + python scripts/verify_adapterless_install.py + +Exits non-zero with a description of the first failure. +""" + +from __future__ import annotations + +import sys + + +def _fail(message: str) -> None: + print(f"FAIL: {message}", file=sys.stderr) + raise SystemExit(1) + + +def main() -> None: + # 1. The transport must import. Before the split this raised + # ModuleNotFoundError, because mqtt/__init__ and mqtt/client both reached + # into _impl/schema_0 at module scope. + try: + import span_panel_api # noqa: F401 + from span_panel_api.mqtt.client import SpanMqttClient + except ModuleNotFoundError as exc: + _fail(f"bootstrap import reaches an adapter package: {exc}") + + # 2. No adapter should be discoverable. If one is, the bootstrap wheel is + # still carrying the entry point and the split did not actually happen. + from span_panel_api.adapters import DEFAULT_ADAPTER_KEY, discover_adapters + + registry = discover_adapters() + if registry: + _fail(f"bootstrap-only install discovered adapters {sorted(registry)}; the entry point did not move") + + # 3. Constructing a client must still work — only building a parser needs an + # adapter. This is what keeps the failure at an actionable point. + from span_panel_api.exceptions import SpanPanelAdapterMissingError + from span_panel_api.mqtt.models import MqttClientConfig + + client = SpanMqttClient( + "panel.local", + "SERIAL123", + MqttClientConfig(broker_host="broker.local", username="u", password="p"), + ) + + # 4. Building a parser must raise the named error, not an opaque one, and + # must say which adapter was wanted. + try: + client._build_adapter(32) # pylint: disable=protected-access + except SpanPanelAdapterMissingError as exc: + if exc.needed != DEFAULT_ADAPTER_KEY: + _fail(f"error names adapter {exc.needed!r}, expected {DEFAULT_ADAPTER_KEY!r}") + if exc.available: + _fail(f"error reports installed adapters {exc.available} in a bootstrap-only install") + except Exception as exc: # pylint: disable=broad-exception-caught + _fail(f"expected SpanPanelAdapterMissingError, got {type(exc).__name__}: {exc}") + else: + _fail("building a parser with no adapter installed did not raise") + + print(f"OK: span-panel-api {span_panel_api.__version__} imports and fails by name with no adapter installed") + + +if __name__ == "__main__": + main() diff --git a/src/span_panel_api/_impl/__init__.py b/src/span_panel_api/_impl/__init__.py deleted file mode 100644 index b7981bd..0000000 --- a/src/span_panel_api/_impl/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Internal implementation packages. Not public API.""" diff --git a/src/span_panel_api/schema_drift.py b/src/span_panel_api/schema_drift.py index 13f3c62..5895e10 100644 --- a/src/span_panel_api/schema_drift.py +++ b/src/span_panel_api/schema_drift.py @@ -3,7 +3,7 @@ Schema-agnostic: operates purely on ``HomieSchemaTypes`` dicts (a mapping of node type to property definitions) and has no dependency on flat-schema (schema_0) internals. Lives at the bootstrap level so ``span_panel_api.mqtt`` -can call it without importing anything from ``_impl/schema_0``. +can call it without importing anything from an adapter distribution. """ from __future__ import annotations diff --git a/tests/conftest.py b/tests/conftest.py index 96e1458..725b21f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,7 @@ import span_panel_api._http as _http_mod from span_panel_api.models import V2HomieSchema -from span_panel_api._impl.schema_0.const import TOPIC_PREFIX, TYPE_CORE +from span_panel_api_schema_0.const import TOPIC_PREFIX, TYPE_CORE @pytest.fixture(autouse=True) diff --git a/tests/test_accumulator.py b/tests/test_accumulator.py index 4e6d216..c33750c 100644 --- a/tests/test_accumulator.py +++ b/tests/test_accumulator.py @@ -21,8 +21,8 @@ import pytest -from span_panel_api._impl.schema_0.accumulator import HomieLifecycle, HomiePropertyAccumulator -from span_panel_api._impl.schema_0.const import TOPIC_PREFIX +from span_panel_api_schema_0.accumulator import HomieLifecycle, HomiePropertyAccumulator +from span_panel_api_schema_0.const import TOPIC_PREFIX SERIAL = "nj-2316-XXXX" PREFIX = f"{TOPIC_PREFIX}/{SERIAL}" diff --git a/tests/test_adapters_discovery.py b/tests/test_adapters_discovery.py index 627cc4e..0b20f30 100644 --- a/tests/test_adapters_discovery.py +++ b/tests/test_adapters_discovery.py @@ -144,7 +144,7 @@ class NotAnAdapter: def test_a_conforming_class_is_registered() -> None: - from span_panel_api._impl.schema_0 import SchemaZeroAdapter + from span_panel_api_schema_0 import SchemaZeroAdapter registry = _discover_with(_FakeEntryPoint("schema_0", SchemaZeroAdapter)) @@ -154,7 +154,7 @@ def test_a_conforming_class_is_registered() -> None: def test_one_bad_adapter_does_not_hide_the_good_ones() -> None: """A broken third-party adapter must not take down a panel whose own adapter is installed and fine.""" - from span_panel_api._impl.schema_0 import SchemaZeroAdapter + from span_panel_api_schema_0 import SchemaZeroAdapter registry = _discover_with( _FakeEntryPoint("schema_9", "not a class"), @@ -165,7 +165,7 @@ def test_one_bad_adapter_does_not_hide_the_good_ones() -> None: def test_an_entry_point_that_raises_on_load_is_skipped() -> None: - from span_panel_api._impl.schema_0 import SchemaZeroAdapter + from span_panel_api_schema_0 import SchemaZeroAdapter class Exploding(_FakeEntryPoint): def load(self) -> object: diff --git a/tests/test_auth_and_homie_helpers.py b/tests/test_auth_and_homie_helpers.py index cf73a1a..dc39277 100644 --- a/tests/test_auth_and_homie_helpers.py +++ b/tests/test_auth_and_homie_helpers.py @@ -8,8 +8,8 @@ import httpx import pytest -from span_panel_api._impl.schema_0.accumulator import HomiePropertyAccumulator -from span_panel_api._impl.schema_0.consumer import HomieDeviceConsumer, _parse_int +from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator +from span_panel_api_schema_0.consumer import HomieDeviceConsumer, _parse_int from span_panel_api.auth import _int, download_ca_cert, get_homie_schema from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelTimeoutError diff --git a/tests/test_factory_dispatch.py b/tests/test_factory_dispatch.py index c50fd80..cc6337f 100644 --- a/tests/test_factory_dispatch.py +++ b/tests/test_factory_dispatch.py @@ -5,7 +5,7 @@ import pytest -from span_panel_api._impl.schema_0 import SchemaZeroAdapter +from span_panel_api_schema_0 import SchemaZeroAdapter from span_panel_api.adapters import _reset_adapter_cache from span_panel_api.exceptions import SpanPanelAdapterMissingError, SpanPanelSchemaVersionError from span_panel_api.factory import _select_adapter_key diff --git a/tests/test_field_metadata.py b/tests/test_field_metadata.py index 333776f..3a85e38 100644 --- a/tests/test_field_metadata.py +++ b/tests/test_field_metadata.py @@ -5,7 +5,7 @@ import logging from span_panel_api.models import FieldMetadata -from span_panel_api._impl.schema_0.field_metadata import build_field_metadata +from span_panel_api_schema_0.field_metadata import build_field_metadata from span_panel_api.schema_drift import log_schema_drift diff --git a/tests/test_mqtt_client_connection.py b/tests/test_mqtt_client_connection.py index b35f0d4..acab9e3 100644 --- a/tests/test_mqtt_client_connection.py +++ b/tests/test_mqtt_client_connection.py @@ -8,7 +8,7 @@ from span_panel_api.exceptions import SpanPanelError, SpanPanelStaleDataError from span_panel_api.models import SpanPanelSnapshot -from span_panel_api._impl.schema_0.const import WILDCARD_TOPIC_FMT +from span_panel_api_schema_0.const import WILDCARD_TOPIC_FMT from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.connection import AsyncMqttBridge from span_panel_api.mqtt.models import MqttClientConfig @@ -458,7 +458,7 @@ def test_client_defaults_to_the_flat_adapter() -> None: than the stored factory keeps the guarantee that mattered — a directly constructed client still parses the flat schema. """ - from span_panel_api._impl.schema_0 import SchemaZeroAdapter + from span_panel_api_schema_0 import SchemaZeroAdapter from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.models import MqttClientConfig @@ -473,7 +473,7 @@ def test_client_defaults_to_the_flat_adapter() -> None: def test_injected_factory_receives_serial_and_panel_size() -> None: """The factory must be called with the panel_size discovered at connect, not a placeholder — panel_size drives unmapped-tab computation.""" - from span_panel_api._impl.schema_0 import SchemaZeroAdapter + from span_panel_api_schema_0 import SchemaZeroAdapter from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.models import MqttClientConfig diff --git a/tests/test_mqtt_homie.py b/tests/test_mqtt_homie.py index e732eea..ece93ae 100644 --- a/tests/test_mqtt_homie.py +++ b/tests/test_mqtt_homie.py @@ -22,9 +22,9 @@ import pytest -from span_panel_api._impl.schema_0 import SchemaZeroAdapter -from span_panel_api._impl.schema_0.accumulator import HomiePropertyAccumulator -from span_panel_api._impl.schema_0.const import ( +from span_panel_api_schema_0 import SchemaZeroAdapter +from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator +from span_panel_api_schema_0.const import ( TOPIC_PREFIX, TYPE_BESS, TYPE_CIRCUIT, @@ -36,7 +36,7 @@ TYPE_POWER_FLOWS, TYPE_PV, ) -from span_panel_api._impl.schema_0.consumer import HomieDeviceConsumer +from span_panel_api_schema_0.consumer import HomieDeviceConsumer from span_panel_api.mqtt.const import HOMIE_STATE_READY, MQTT_DEFAULT_MQTTS_PORT, MQTT_DEFAULT_WS_PORT, MQTT_DEFAULT_WSS_PORT from span_panel_api.mqtt.connection import AsyncMqttBridge from span_panel_api.mqtt.models import MqttClientConfig @@ -178,18 +178,18 @@ def test_ignores_set_topics(self): class TestHomieCircuitSnapshot: def test_circuit_id_normalization(self): - from span_panel_api._impl.schema_0.const import normalize_circuit_id + from span_panel_api_schema_0.const import normalize_circuit_id assert normalize_circuit_id("aabbccdd-1122-3344-5566-778899001122") == "aabbccdd11223344556677889900112" + "2" def test_circuit_id_denormalization(self): - from span_panel_api._impl.schema_0.const import denormalize_circuit_id + from span_panel_api_schema_0.const import denormalize_circuit_id result = denormalize_circuit_id("aabbccdd11223344556677889900112" + "2") assert result == "aabbccdd-1122-3344-5566-778899001122" def test_denormalize_non_uuid(self): - from span_panel_api._impl.schema_0.const import denormalize_circuit_id + from span_panel_api_schema_0.const import denormalize_circuit_id # Non-32-char strings pass through unchanged assert denormalize_circuit_id("short") == "short" diff --git a/tests/test_protocol_conformance.py b/tests/test_protocol_conformance.py index 50101fc..48ee23e 100644 --- a/tests/test_protocol_conformance.py +++ b/tests/test_protocol_conformance.py @@ -91,7 +91,7 @@ def test_schema_adapter_construction_signature_matches_its_implementation() -> N """ import inspect - from span_panel_api._impl.schema_0 import SchemaZeroAdapter + from span_panel_api_schema_0 import SchemaZeroAdapter from span_panel_api.protocol import SchemaAdapter declared = list(inspect.signature(SchemaAdapter.__init__).parameters) diff --git a/tests/test_schema_zero_adapter.py b/tests/test_schema_zero_adapter.py index 0c6f2d8..cf35094 100644 --- a/tests/test_schema_zero_adapter.py +++ b/tests/test_schema_zero_adapter.py @@ -10,7 +10,7 @@ import pytest -from span_panel_api._impl.schema_0 import SchemaZeroAdapter +from span_panel_api_schema_0 import SchemaZeroAdapter from span_panel_api.protocol import SchemaAdapter SERIAL = "sim-40t-001" diff --git a/uv.lock b/uv.lock index 1ff3b6c..9c10a96 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[manifest] +members = [ + "span-panel-api", + "span-panel-api-schema-0", +] + [[package]] name = "anyio" version = "4.12.1" @@ -488,7 +494,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -576,7 +582,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.12'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -1292,7 +1298,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "2.6.4" +version = "3.0.0b1" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -1313,6 +1319,7 @@ dev = [ { name = "pytest-cov" }, { name = "radon" }, { name = "ruff" }, + { name = "span-panel-api-schema-0" }, { name = "twine" }, { name = "types-pyyaml" }, { name = "vulture" }, @@ -1338,11 +1345,23 @@ dev = [ { name = "pytest-cov" }, { name = "radon" }, { name = "ruff", specifier = ">=0.15.5" }, + { name = "span-panel-api-schema-0", editable = "packages/schema-0" }, { name = "twine" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, { name = "vulture", specifier = ">=2.14" }, ] +[[package]] +name = "span-panel-api-schema-0" +version = "1.0.0b1" +source = { editable = "packages/schema-0" } +dependencies = [ + { name = "span-panel-api" }, +] + +[package.metadata] +requires-dist = [{ name = "span-panel-api", editable = "." }] + [[package]] name = "stevedore" version = "5.7.0" From 3ca783582668200d7d10cbb2a2965792ab7b5930 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:29:47 -0700 Subject: [PATCH 015/115] fix(ci): measure adapter-package coverage after the split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local coverage hook passed --cov=src/span_panel_api explicitly, so moving the adapter out of src/ silently dropped 543 statements from the report — coverage looked fine at 91.6% while the entire flat parser went unmeasured. Both source roots are now passed, and the real figure is 94%. --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2b25c4c..9054fbd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -131,6 +131,6 @@ repos: name: coverage summary entry: bash language: system - args: ['-c', 'output=$(uv run pytest tests/ --cov=src/span_panel_api --cov-config=pyproject.toml --cov-fail-under=85 -q 2>&1); status=$?; echo "$output"; exit "$status"'] + args: ['-c', 'output=$(uv run pytest tests/ --cov=src/span_panel_api --cov=packages/schema-0/src/span_panel_api_schema_0 --cov-config=pyproject.toml --cov-fail-under=85 -q 2>&1); status=$?; echo "$output"; exit "$status"'] pass_filenames: false verbose: true From fa6dd8acdd57bf78818ca716d141304fc8b3fd2f Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:32:42 -0700 Subject: [PATCH 016/115] test(schema_0): assert hardcoded schema facts still resolve against source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design doc testing item 8, clauses 8a and 8b — the only signal that catches adapter-axis drift before release. Every other symptom of "SPAN changed the schema and we did not notice" reaches production as a silent absence: a property that stops arriving, a metadata lookup that returns None, an entity that goes unavailable with no error anywhere. The same failure already happened upstream (python-sdk#27 was exactly a hardcoded fact that had stopped resolving), which is why it is worth having with one adapter rather than waiting for schema_1. 8b records the schema revision this adapter was written against (sha256:d347556a07d98f40, spanos2/r202603/05) as SCHEMA_ANCHOR in the adapter package rather than the bootstrap, because the field is renamed with the block it covers: flat serves typesSchemaHash over `types`, parent/child serves deviceClassesSchemaHash over `deviceClasses`. schema_1 declares its own. The hash is content-derived, so it moves when the schema moves rather than on every firmware build, which is what makes it an anchor and not noise. 8a checks all 64 (node_type, property_id) rows in _PROPERTY_FIELD_MAP through the same lookup path build_field_metadata uses, plus HOMIE_DOMAIN / HOMIE_VERSION against homieDomain / homieVersion. It also pins the two type namespaces apart: TYPE_LUGS_UPSTREAM and TYPE_LUGS_DOWNSTREAM are real wire types that the schema does not declare, so they are asserted *absent* from `types` and *present* in _LUGS_FALLBACK — a wire-only subtype without an alias silently yields no property metadata, and that is now caught. Records one standing disagreement as an assertion rather than a comment: the schema declares circuit active-power in kW and real panels publish W. The instinct on finding that is to "fix" the code back to kW, which would reintroduce the 1000x error 1eef0dc removed after checking real hardware. The test fails if SPAN ever corrects the schema, and says to delete itself. 8c (does SUPPORTS_DATA_MODEL_VERSIONS still cover the reported version) is deliberately absent: flat firmware publishes no version to compare against. --- .../src/span_panel_api_schema_0/const.py | 20 ++ tests/test_schema_provenance.py | 177 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 tests/test_schema_provenance.py diff --git a/packages/schema-0/src/span_panel_api_schema_0/const.py b/packages/schema-0/src/span_panel_api_schema_0/const.py index 6e85d5f..e94dc5c 100644 --- a/packages/schema-0/src/span_panel_api_schema_0/const.py +++ b/packages/schema-0/src/span_panel_api_schema_0/const.py @@ -1,5 +1,25 @@ """Constants for the flat-schema (Homie v5) parsing implementation.""" +# --------------------------------------------------------------------------- +# Provenance anchor — the schema revision every fact in this module was read +# from. `tests/test_schema_provenance.py` fails when a captured schema reports a +# different one, which is the only pre-release signal that this adapter has +# drifted from the wire it claims to parse. +# +# The field name is per-adapter, not per-bootstrap: flat firmware publishes +# `typesSchemaHash` over a `types` block, while parent/child renames it to +# `deviceClassesSchemaHash` over `deviceClasses` — the hash is renamed with the +# block it covers, so schema_1 declares its own. +# +# Content-derived, not build-derived: SPAN defines it as the SHA-256 of the +# canonicalized schema object and states the schema "may remain unchanged across +# multiple firmware releases". So it moves when the schema moves, not on every +# release — which is what makes it usable as an anchor rather than noise. +# --------------------------------------------------------------------------- +SCHEMA_ANCHOR_FIELD = "typesSchemaHash" +SCHEMA_ANCHOR = "sha256:d347556a07d98f40" +SCHEMA_ANCHOR_FIRMWARE = "spanos2/r202603/05" + # Homie v5 topic structure HOMIE_VERSION = 5 HOMIE_DOMAIN = "ebus" diff --git a/tests/test_schema_provenance.py b/tests/test_schema_provenance.py new file mode 100644 index 0000000..f628836 --- /dev/null +++ b/tests/test_schema_provenance.py @@ -0,0 +1,177 @@ +"""Provenance checks — do this adapter's hardcoded facts still match the wire? + +Design doc testing item 8, clauses 8a and 8b. This is the **only** signal that +catches adapter-axis drift before release. Every other symptom of "SPAN changed +the schema and we did not notice" shows up in production as a silent absence: a +property that stops arriving, a metadata lookup that quietly returns None, an +entity that goes unavailable without an error anywhere. + +The failure this guards against has already happened once upstream +(electrification-bus/python-sdk#27 was exactly a hardcoded fact that had stopped +resolving against its source), which is why it is worth having with a single +adapter rather than waiting for schema_1 to make comparison interesting. + +Clause 8c (does SUPPORTS_DATA_MODEL_VERSIONS still cover what the panel reports) +is deliberately absent: flat firmware publishes no version to compare against, +and the check only becomes meaningful with a second adapter. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from span_panel_api_schema_0 import const +from span_panel_api_schema_0.field_metadata import _LUGS_FALLBACK, _PROPERTY_FIELD_MAP, _lookup_property + +_FIXTURE = Path(__file__).parent / "fixtures" / "v2" / "homie_schema.json" + + +@pytest.fixture(name="schema") +def _schema() -> dict[str, Any]: + """The captured `GET /api/v2/homie/schema` response — our stand-in for the panel.""" + with _FIXTURE.open() as handle: + loaded: dict[str, Any] = json.load(handle) + return loaded + + +# --------------------------------------------------------------------------- +# 8b — anchor check +# --------------------------------------------------------------------------- + + +def test_captured_schema_still_matches_the_anchor(schema: dict[str, Any]) -> None: + """The schema revision this adapter was written against. + + A mismatch does not mean the adapter is broken — it means the schema moved + and every fact below is now unverified until someone looks. That is the + whole job of an anchor: convert a silent change into a visible one. + """ + assert schema[const.SCHEMA_ANCHOR_FIELD] == const.SCHEMA_ANCHOR, ( + f"Schema hash moved from {const.SCHEMA_ANCHOR} to {schema[const.SCHEMA_ANCHOR_FIELD]}. " + "Re-verify the facts in const.py and _PROPERTY_FIELD_MAP against the new schema, " + "then update SCHEMA_ANCHOR." + ) + + +def test_anchor_field_is_the_flat_era_name(schema: dict[str, Any]) -> None: + """Flat serves `typesSchemaHash` over `types`; parent/child renames both to + `deviceClassesSchemaHash` over `deviceClasses`. + + Pinning the name here is what stops schema_1 from inheriting a field that + does not exist on its firmware and silently getting no anchor at all. + """ + assert const.SCHEMA_ANCHOR_FIELD in schema + assert "deviceClassesSchemaHash" not in schema, "this fixture is parent/child, not flat" + assert schema["firmwareVersion"] == const.SCHEMA_ANCHOR_FIRMWARE + + +# --------------------------------------------------------------------------- +# 8a — hardcoded facts resolve against source +# --------------------------------------------------------------------------- + + +def test_homie_domain_and_version_match_the_schema(schema: dict[str, Any]) -> None: + """TOPIC_PREFIX is built from these two, so every topic this adapter + subscribes to or publishes depends on them being right.""" + assert const.HOMIE_DOMAIN == schema["homieDomain"] + assert const.HOMIE_VERSION == schema["homieVersion"] + assert const.TOPIC_PREFIX == f"{schema['homieDomain']}/{schema['homieVersion']}" + + +# Node types this adapter restates from the schema's `types` block. +_SCHEMA_DECLARED_TYPES = ( + const.TYPE_CORE, + const.TYPE_LUGS, + const.TYPE_CIRCUIT, + const.TYPE_BESS, + const.TYPE_PV, + const.TYPE_EVSE, + const.TYPE_POWER_FLOWS, +) + +# Node types real firmware publishes in $description but the schema does not +# declare. See const.py: the schema carries only the base lugs type. +_WIRE_ONLY_TYPES = ( + const.TYPE_LUGS_UPSTREAM, + const.TYPE_LUGS_DOWNSTREAM, +) + + +@pytest.mark.parametrize("node_type", _SCHEMA_DECLARED_TYPES) +def test_declared_node_types_exist_in_the_schema(node_type: str, schema: dict[str, Any]) -> None: + assert node_type in schema["types"], f"{node_type} is no longer a declared type" + + +@pytest.mark.parametrize("node_type", _WIRE_ONLY_TYPES) +def test_wire_only_types_are_absent_but_aliased(node_type: str, schema: dict[str, Any]) -> None: + """The two namespaces are not the same set, and this pins both halves. + + These types are real — confirmed against a live panel — but undeclared, so + a metadata lookup for them only works through the alias. If SPAN ever + *declares* them, the alias becomes wrong and this test says so. If someone + adds another wire-only subtype without an alias, property metadata silently + comes back empty for those nodes and this test catches that too. + """ + assert ( + node_type not in schema["types"] + ), f"{node_type} is now declared in the schema; the _LUGS_FALLBACK alias may no longer be correct" + assert node_type in _LUGS_FALLBACK, f"{node_type} is undeclared and unaliased — metadata lookups will return None" + assert _LUGS_FALLBACK[node_type] in schema["types"] + + +def test_every_mapped_property_resolves_against_the_schema(schema: dict[str, Any]) -> None: + """The core 8a assertion. + + `_PROPERTY_FIELD_MAP` is ~70 hardcoded (node_type, property_id) pairs, each + asserting a property exists on the wire. Every one must resolve through the + same lookup path `build_field_metadata` uses — otherwise that field silently + gets no unit and no datatype, and the integration renders an entity with no + device class rather than failing. + """ + unresolved = [ + f"{node_type}/{property_id} -> {field_path}" + for node_type, property_id, field_path in _PROPERTY_FIELD_MAP + if _lookup_property(schema["types"], node_type, property_id) is None + ] + + assert not unresolved, "hardcoded properties no longer in the schema:\n " + "\n ".join(unresolved) + + +def test_no_mapped_property_is_missing_a_field_path() -> None: + """Every mapping row must name a snapshot field, and no two rows may claim + the same one — a duplicate means one silently overwrites the other.""" + field_paths = [field_path for _, _, field_path in _PROPERTY_FIELD_MAP] + + assert all(field_paths), "a mapping row has an empty field path" + duplicates = {path for path in field_paths if field_paths.count(path) > 1} + assert not duplicates, f"field paths claimed by more than one property: {sorted(duplicates)}" + + +# --------------------------------------------------------------------------- +# Known, deliberate disagreements with the schema +# --------------------------------------------------------------------------- + + +def test_circuit_active_power_unit_still_disagrees_with_the_schema(schema: dict[str, Any]) -> None: + """The schema says kW. Real panels publish W. We follow the panel. + + Recorded as an asserted expectation rather than a comment because it is a + standing contradiction between our implementation and the published schema, + and the natural instinct on finding it is to "fix" the code back to kW — + which would reintroduce the 1000x error that 1eef0dc removed after checking + against real hardware. + + When this test fails because the schema now says W, the disagreement is over: + delete this test. It failing is good news. + """ + declared = schema["types"][const.TYPE_CIRCUIT]["active-power"]["unit"] + + assert declared == "kW", ( + "The schema now declares circuit active-power as " + f"{declared!r} rather than 'kW'. If that is 'W', the long-standing " + "schema-versus-hardware disagreement is resolved and this test should be deleted." + ) From 1a2999074afc5fbb9296ead7e58365938c2d0999 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:34:29 -0700 Subject: [PATCH 017/115] docs: changelogs for 3.0.0b1 and schema-0 1.0.0b1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the two breaking changes users will hit — the bootstrap no longer containing a parser, and the three flat-schema names leaving the public API — with the install command that resolves the first. The adapter changelog opens by stating which axis it versions on. That package's number tracks the parser, never the wire format it parses; the wire format is fixed and identified by SUPPORTS_DATA_MODEL_VERSIONS. Confusing the two is the failure mode the two-axis split exists to prevent, so it is worth saying in the file people read when deciding to upgrade. Also records the two known deviations from the published schema (circuit active-power in W not kW, and the two undeclared lugs subtypes) where a consumer will actually look for them. --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++ packages/schema-0/CHANGELOG.md | 33 ++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 packages/schema-0/CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 58882ac..4050bd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,46 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0b1] - 08/2026 + +Pre-release. `span-panel-api` becomes a transport and a dispatcher that contains **no parser**. Wire formats ship as separate distributions and register themselves via entry points, so support for a new panel schema arrives by installing a package rather +than by upgrading the transport. This is prototype work being proven end to end before any decision to land it on `main`. + +### Removed + +- **BREAKING: `span-panel-api` no longer contains a parser.** Installing it alone gives a client that connects and then raises `SpanPanelAdapterMissingError`. Flat-schema panels (firmware `r202603`–`r202627`) need **`span-panel-api-schema-0`** installed + alongside it: + + ```console + pip install span-panel-api span-panel-api-schema-0 + ``` + +- **BREAKING: `HomieLifecycle`, `HomiePropertyAccumulator` and `HomieDeviceConsumer` are no longer exported** from `span_panel_api` or `span_panel_api.mqtt`. All three are flat-schema-specific rather than Homie-convention-level: the accumulator filters + every topic against a single device's prefix and stores `node → prop`, which drops nearly every message under the parent/child model; `HomieLifecycle`'s members are not Homie 5 `$state` values but a consumer-side progression encoding "one description + received ⇒ ready", which is the flat readiness model. They now live in `span_panel_api_schema_0`. +- **Removed dead constants** `DEVICE_TOPIC_FMT`, `STATE_TOPIC_FMT`, `DESCRIPTION_TOPIC_FMT`, `PROPERTY_TOPIC_FMT` (unreferenced before the Phase 0 relocation) and `TYPE_PCS` (a real schema type this library does not consume). + +### Added + +- **`span_panel_api.adapters.resolve_adapter(key, reason)`** — the single place a missing adapter becomes a named error, used by both Tier 1 dispatch and the transport's default path. +- **`SpanPanelSchemaVersionError`**, raised when a panel reports a `data-model-version` whose schema major cannot be determined. Distinct from `SpanPanelAdapterMissingError` because the remedy differs: a missing adapter is a known schema with no installed + parser, while this is a schema no adapter can even be named for. +- **`SpanPanelAdapterMissingError` and `SpanPanelSchemaVersionError` are now exported** from the top-level package — both are errors a user sees when their panel outruns their install, so catching them should not require reaching into a private module. +- **`SchemaAdapter.__init__` is declared on the protocol.** Construction was always part of the contract (the transport resolves an adapter class from the registry and calls it), but was previously typed only as a `Callable`, leaving the signature + unchecked against implementations. +- **Entry-point validation.** `discover_adapters()` now verifies each loaded object is a class implementing the protocol before registering it, and skips it with a logged reason otherwise. One broken third-party adapter cannot take down a panel whose own + adapter is fine. +- **`scripts/verify_adapterless_install.py`** and a CI step that runs it against a venv holding only the bootstrap wheel. + +### Changed + +- **`SpanMqttClient(adapter_factory=...)` is now optional.** When omitted, the parser is resolved through entry-point discovery at `_build_adapter()` rather than imported. Resolution is lazy by design: constructing a client must not require an adapter to + be installed, only building a parser must. +- **Dispatch refuses an unreadable `data-model-version` instead of assuming flat.** Absence still means the flat schema — that is a real signal, since the property was introduced by the firmware that introduced parent/child. A value whose major _can_ be + read but whose form is non-canonical (`1`, `1.0-beta`) dispatches on that major and logs the deviation. A value with no extractable major now raises. Previously all three fell through to the flat parser, which does not fail — it produces plausible but + wrong power and energy figures. +- **Dispatch diagnostics travel through the `SpanMqttClient` constructor**, removing the window where a connected client reported a selected adapter alongside `schema_dispatch_reason='not dispatched'`. + ## [2.6.4] - 05/2026 ### Fixed diff --git a/packages/schema-0/CHANGELOG.md b/packages/schema-0/CHANGELOG.md new file mode 100644 index 0000000..e98aa4d --- /dev/null +++ b/packages/schema-0/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to `span-panel-api-schema-0` are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is fixed — the flat single-device schema, SPAN firmware `r202603` through `r202627` — and is identified by `SUPPORTS_DATA_MODEL_VERSIONS` +rather than by this version number. A release here means this parser changed, never that the panel did. + +## [1.0.0b1] - 08/2026 + +Pre-release. First release as a standalone distribution. + +### Added + +- **The flat-schema parser, extracted from `span-panel-api` 2.6.4.** Relocated verbatim from `span_panel_api._impl.schema_0` to `span_panel_api_schema_0`; only import statements changed. Registers itself as `schema_0` under the + `span_panel_api.schema_adapters` entry-point group, which is the only way `span-panel-api` reaches it — the bootstrap never imports this package. +- **`SCHEMA_ANCHOR`** (`sha256:d347556a07d98f40`, firmware `spanos2/r202603/05`) — the schema revision every hardcoded fact in this package was read from, with `SCHEMA_ANCHOR_FIELD` naming the field it comes from (`typesSchemaHash`). The field is + per-adapter: parent/child firmware renames it to `deviceClassesSchemaHash` along with the block it covers, so a future `schema_1` declares its own rather than inheriting one that does not exist on its firmware. +- **Provenance tests** asserting that all 64 hardcoded `(node_type, property_id)` pairs still resolve against the captured schema, that `HOMIE_DOMAIN` / `HOMIE_VERSION` still match it, and that the two lugs subtypes real firmware publishes remain absent + from the schema _and_ present in the metadata alias table. This is the only signal that catches schema drift before release; every other symptom reaches production as a silent absence. + +### Known deviations from the published schema + +- **Circuit `active-power` is treated as watts, though the schema declares kilowatts.** Real panels publish watts; this was established against live hardware and the 1000× correction was removed accordingly. A test asserts the schema still says `kW`, so + the day SPAN corrects it we find out rather than discovering it as a factor-of-1000 error. +- **`energy.ebus.device.lugs.upstream` / `.downstream` are parsed but undeclared.** Firmware publishes these node types in `$description`; the schema declares only the base `energy.ebus.device.lugs`. Property metadata for them resolves through an alias to + the base type. + +### Retirement + +SPAN retires the flat schema in the same firmware release that introduces the parent/child model (`r202633`; fleet rollout projected, not committed, for the first two weeks of September 2026). This package stops being published once the fleet has moved. +Published versions remain on PyPI for anyone still running older firmware. From a598d52146268bf760d1fbfbb8af106ba4f8303b Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:28:08 -0700 Subject: [PATCH 018/115] fix: make the release publishable and the adapter type-visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release workflow was never updated for the two-distribution layout. It built the root package only and rewrote the root version from the tag, so publishing 3.0.0b1 would have shipped a bootstrap with no adapter on PyPI — every install connecting and then raising SpanPanelAdapterMissingError, with the package that fixes it not existing. A tag now selects a distribution rather than setting a version: vX.Y.Z -> span-panel-api schema-N-vX.Y.Z -> span-panel-api-schema-N and the job fails unless the tag matches the version committed in that distribution's pyproject.toml. Stamping the version at release time is not extensible to two packages and is now actively wrong: the adapter declares a dependency floor on the bootstrap, so both committed versions participate in resolution and cannot be treated as placeholders. The adapter distribution shipped without a py.typed marker — it did not travel with the code when the parser moved out of src/span_panel_api, which has one. Fully annotated, strict-clean code resolved as Any for every downstream consumer. Verified against installed wheels: strict mypy reported import-untyped and 'Revealed type is "Any"' before, real types after. Both CI and the release job now reject a wheel built without the marker. Two review findings alongside: - factory dispatch returned the literal "schema_0" while the transport's default path used DEFAULT_ADAPTER_KEY. Two sources of truth for the key the two callers of resolve_adapter must agree on, and a divergence is invisible in a dev workspace where every adapter is installed. - required-member derivation screened vars() for callable, which reads as equivalent to "every declared member" and is not: property and classmethod objects are not callable, so a protocol member of either kind would have stopped being required without anyone noticing. The derivation is extracted into a function so the rule is testable against a synthetic protocol rather than asserted in a comment. 432 tests pass, coverage 94%. --- .github/workflows/ci.yml | 16 ++++ .github/workflows/release.yml | 73 +++++++++++++++++-- CHANGELOG.md | 10 +++ packages/schema-0/CHANGELOG.md | 1 + .../src/span_panel_api_schema_0/py.typed | 0 src/span_panel_api/adapters.py | 40 +++++++--- src/span_panel_api/factory.py | 4 +- tests/test_adapters_discovery.py | 48 ++++++++++++ tests/test_factory_dispatch.py | 13 ++++ tests/test_packaging.py | 61 ++++++++++++++++ 10 files changed, 247 insertions(+), 19 deletions(-) create mode 100644 packages/schema-0/src/span_panel_api_schema_0/py.typed create mode 100644 tests/test_packaging.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23b0983..e8d6c9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,22 @@ jobs: - name: Check packages run: uv run twine check dist/* + # Every distribution here is fully annotated, so every distribution has to + # carry the marker that lets a consumer's type checker see those annotations. + # Without it the package resolves to Any downstream and the typing is inert. + - name: Verify every wheel ships a py.typed marker + run: | + python -c " + import glob, sys, zipfile + wheels = glob.glob('dist/*.whl') + if not wheels: + sys.exit('::error::no wheels were built') + for wheel in wheels: + if not any(n.endswith('/py.typed') for n in zipfile.ZipFile(wheel).namelist()): + sys.exit(f'::error::{wheel} ships no py.typed marker; downstream type checking would resolve it as Any') + print(f'{wheel}: py.typed present') + " + # The configuration entry-point discovery exists to support, and the one # nothing else in CI exercises: the bootstrap wheel installed with no # adapter present. It must import, and it must fail by name rather than diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba05d5f..dd505dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,18 @@ on: release: types: [published] +# This repo publishes two distributions that version independently: the +# bootstrap (span-panel-api) and each schema adapter (span-panel-api-schema-N). +# One release publishes exactly one of them, chosen by the tag prefix: +# +# v3.0.0b1 -> span-panel-api (the historical convention) +# schema-0-v1.0.0b1 -> span-panel-api-schema-0 +# +# The version lives in the distribution's own pyproject.toml and this workflow +# only verifies the tag agrees. It deliberately does not rewrite the version at +# release time: the adapter declares a floor on the bootstrap +# (span-panel-api>=X), so the committed versions are load-bearing for resolution +# and cannot be treated as placeholders that a release stamps over. jobs: deploy: runs-on: ubuntu-latest @@ -25,16 +37,63 @@ jobs: with: enable-cache: true - - name: Update version from tag + - name: Resolve the distribution from the tag + id: target run: | - # Extract version from git tag (remove 'v' prefix if present) - VERSION=${GITHUB_REF#refs/tags/} - VERSION=${VERSION#v} - echo "Setting version to $VERSION" - sed -i "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml + TAG=${GITHUB_REF#refs/tags/} + case "$TAG" in + schema-*-v*) + SCHEMA=${TAG#schema-} + SCHEMA=${SCHEMA%%-v*} + PACKAGE="span-panel-api-schema-$SCHEMA" + MANIFEST="packages/schema-$SCHEMA/pyproject.toml" + VERSION=${TAG#schema-$SCHEMA-v} + ;; + v*) + PACKAGE="span-panel-api" + MANIFEST="pyproject.toml" + VERSION=${TAG#v} + ;; + *) + echo "::error::Tag '$TAG' names no distribution. Use 'vX.Y.Z' for the bootstrap or 'schema-N-vX.Y.Z' for an adapter." + exit 1 + ;; + esac + if [ ! -f "$MANIFEST" ]; then + echo "::error::Tag '$TAG' resolves to '$MANIFEST', which does not exist." + exit 1 + fi + echo "Tag '$TAG' releases $PACKAGE $VERSION from $MANIFEST" + echo "package=$PACKAGE" >> "$GITHUB_OUTPUT" + echo "manifest=$MANIFEST" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Verify the tag matches the committed version + run: | + DECLARED=$(python -c "import sys, tomllib; print(tomllib.load(open(sys.argv[1], 'rb'))['project']['version'])" "${{ steps.target.outputs.manifest }}") + if [ "$DECLARED" != "${{ steps.target.outputs.version }}" ]; then + echo "::error::${{ steps.target.outputs.manifest }} declares version '$DECLARED' but the tag says '${{ steps.target.outputs.version }}'. Commit the version bump before tagging." + exit 1 + fi + echo "Version $DECLARED confirmed." + + # Only the tagged distribution is built, so dist/ holds exactly what this + # release publishes and the publish step cannot pick up a sibling package. - name: Build package - run: uv build + run: uv build --package "${{ steps.target.outputs.package }}" + + - name: Verify the wheel ships a py.typed marker + run: | + python -c " + import glob, sys, zipfile + wheels = glob.glob('dist/*.whl') + if not wheels: + sys.exit('::error::no wheel was built') + for wheel in wheels: + if not any(n.endswith('/py.typed') for n in zipfile.ZipFile(wheel).namelist()): + sys.exit(f'::error::{wheel} ships no py.typed marker; downstream type checking would resolve it as Any') + print(f'{wheel}: py.typed present') + " - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4050bd1..c4df4ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,16 @@ than by upgrading the transport. This is prototype work being proven end to end read but whose form is non-canonical (`1`, `1.0-beta`) dispatches on that major and logs the deviation. A value with no extractable major now raises. Previously all three fell through to the flat parser, which does not fail — it produces plausible but wrong power and energy figures. - **Dispatch diagnostics travel through the `SpanMqttClient` constructor**, removing the window where a connected client reported a selected adapter alongside `schema_dispatch_reason='not dispatched'`. +- **Releases are now per-distribution and the tag no longer sets the version.** A tag selects which distribution to publish — `vX.Y.Z` for `span-panel-api`, `schema-N-vX.Y.Z` for an adapter — and the release fails unless the tagged version matches the one + committed in that distribution's `pyproject.toml`. The previous workflow rewrote the root version from the tag and built only the root package, which under a two-distribution layout would have published the bootstrap with no adapter alongside it. Version + numbers are now load-bearing between the two (the adapter declares a floor on the bootstrap), so they belong in the repository rather than being stamped at release time. + +### Fixed + +- **Adapter distributions ship a `py.typed` marker.** Without it a consumer's type checker refuses to read the adapter's annotations and resolves every symbol it exports as `Any`, silently erasing the strict typing at the wheel boundary. CI now fails any + wheel built without one. +- **Protocol conformance checking no longer depends on member kind.** The required-member set is derived from every public member `SchemaAdapter` declares, not only the callable ones — a `property` or `classmethod` object is not callable, so the previous + derivation would have quietly stopped requiring such a member the day the protocol declared one. ## [2.6.4] - 05/2026 diff --git a/packages/schema-0/CHANGELOG.md b/packages/schema-0/CHANGELOG.md index e98aa4d..e3ef55e 100644 --- a/packages/schema-0/CHANGELOG.md +++ b/packages/schema-0/CHANGELOG.md @@ -19,6 +19,7 @@ Pre-release. First release as a standalone distribution. per-adapter: parent/child firmware renames it to `deviceClassesSchemaHash` along with the block it covers, so a future `schema_1` declares its own rather than inheriting one that does not exist on its firmware. - **Provenance tests** asserting that all 64 hardcoded `(node_type, property_id)` pairs still resolve against the captured schema, that `HOMIE_DOMAIN` / `HOMIE_VERSION` still match it, and that the two lugs subtypes real firmware publishes remain absent from the schema _and_ present in the metadata alias table. This is the only signal that catches schema drift before release; every other symptom reaches production as a silent absence. +- **A `py.typed` marker**, so consumers type-check against this package's real annotations rather than resolving everything it exports as `Any`. ### Known deviations from the published schema diff --git a/packages/schema-0/src/span_panel_api_schema_0/py.typed b/packages/schema-0/src/span_panel_api_schema_0/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/span_panel_api/adapters.py b/src/span_panel_api/adapters.py index d4f92e9..2ddfe71 100644 --- a/src/span_panel_api/adapters.py +++ b/src/span_panel_api/adapters.py @@ -17,16 +17,36 @@ _ENTRY_POINT_GROUP = "span_panel_api.schema_adapters" _REGISTRY: dict[str, type[SchemaAdapter]] | None = None -# Derived from the protocol rather than restated, so the check cannot drift out -# of sync with the contract it enforces — adding a method to SchemaAdapter -# automatically makes it required of every adapter package. -# -# `issubclass` is not available here: SchemaAdapter has non-method members, and -# runtime_checkable protocols with data attributes reject issubclass() outright. -_REQUIRED_MEMBERS: tuple[str, ...] = ( - *sorted(SchemaAdapter.__annotations__), - *sorted(name for name, value in vars(SchemaAdapter).items() if callable(value) and not name.startswith("_")), -) + +def _derive_required_members(protocol: type) -> tuple[str, ...]: + """Every public member a protocol declares, whatever kind it is. + + Derived from the protocol rather than restated, so the check cannot drift + out of sync with the contract it enforces — adding any public member to + SchemaAdapter automatically makes it required of every adapter package. + + Two sources, because a protocol declares members two ways: annotation-only + data members live in ``__annotations__`` and never reach ``vars()``, while + anything with a body lives in ``vars()`` and is not annotated. + + Member *kind* is deliberately not filtered on. Screening ``vars()`` for + ``callable`` looks equivalent and is not: a ``property`` object is not + callable and neither is a ``classmethod`` object, so that filter would + silently stop requiring a member the day the protocol declared one. Every + public name in ``vars()`` is a member the protocol body declared — Protocol's + own machinery (``_is_protocol``, ``__protocol_attrs__``, ``__subclasshook__``) + is uniformly underscore-prefixed — so no kind check is needed to begin with. + + ``issubclass`` is not an option here: SchemaAdapter has non-method members, + and runtime_checkable protocols with data attributes reject it outright. + """ + return ( + *sorted(getattr(protocol, "__annotations__", {})), + *sorted(name for name in vars(protocol) if not name.startswith("_")), + ) + + +_REQUIRED_MEMBERS: tuple[str, ...] = _derive_required_members(SchemaAdapter) # The adapter key for panels that publish no data-model-version. This is a # bootstrap-level fact — Tier 1 dispatch reads absence as "flat schema" — not an diff --git a/src/span_panel_api/factory.py b/src/span_panel_api/factory.py index 2f7e125..dd066d1 100644 --- a/src/span_panel_api/factory.py +++ b/src/span_panel_api/factory.py @@ -9,7 +9,7 @@ import logging import re -from .adapters import resolve_adapter +from .adapters import DEFAULT_ADAPTER_KEY, resolve_adapter from .auth import register_v2 from .detection import detect_api_version from .exceptions import SpanPanelAuthError, SpanPanelSchemaVersionError @@ -51,7 +51,7 @@ def _select_adapter_key(data_model_version: str | None) -> tuple[str, str]: extracted from it. """ if data_model_version is None: - return "schema_0", "data-model-version absent (flat schema)" + return DEFAULT_ADAPTER_KEY, "data-model-version absent (flat schema)" if (match := _DMV_CANONICAL.match(data_model_version)) is not None: return f"schema_{int(match.group(1))}", f"data-model-version={data_model_version!r}" diff --git a/tests/test_adapters_discovery.py b/tests/test_adapters_discovery.py index 0b20f30..80d3a28 100644 --- a/tests/test_adapters_discovery.py +++ b/tests/test_adapters_discovery.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import Protocol from unittest.mock import patch import pytest @@ -121,6 +122,53 @@ def test_required_members_are_derived_from_the_protocol() -> None: assert not [member for member in _REQUIRED_MEMBERS if member.startswith("_")] +def test_every_kind_of_declared_member_is_required_not_just_plain_methods() -> None: + """A property is not callable and neither is a classmethod object, so a + kind-filtered derivation would silently stop requiring them. SchemaAdapter + declares only plain methods today; this pins the rule before it declares more.""" + from span_panel_api.adapters import _derive_required_members + + class SurfaceProbe(Protocol): + annotated: str + + @property + def a_property(self) -> int: ... + + @classmethod + def a_classmethod(cls) -> None: ... + + @staticmethod + def a_staticmethod() -> None: ... + + def a_method(self) -> None: ... + + assert set(_derive_required_members(SurfaceProbe)) == { + "annotated", + "a_property", + "a_classmethod", + "a_staticmethod", + "a_method", + } + + +def test_an_adapter_missing_a_non_method_member_is_still_rejected() -> None: + """The end-to-end consequence of the rule above: presence checking has to + reach members that are not plain methods, or a defective adapter registers. + + Built from _REQUIRED_MEMBERS so it stays honest as the protocol grows: the + 'complete' half proves the fixture really does satisfy the check, which is + what makes the 'incomplete' half's rejection attributable to the one + removed member rather than to an unrelated gap. + """ + from span_panel_api.adapters import _REQUIRED_MEMBERS + + complete = {name: (lambda self, *args, **kwargs: None) for name in _REQUIRED_MEMBERS} + incomplete = {name: value for name, value in complete.items() if name != "SUPPORTS_DATA_MODEL_VERSIONS"} + + assert _discover_with(_FakeEntryPoint("schema_9", type("Complete", (), complete))) != {} + assert _discover_with(_FakeEntryPoint("schema_9", type("Incomplete", (), incomplete))) == {} + + @pytest.mark.parametrize( ("label", "value"), [ diff --git a/tests/test_factory_dispatch.py b/tests/test_factory_dispatch.py index cc6337f..5326c84 100644 --- a/tests/test_factory_dispatch.py +++ b/tests/test_factory_dispatch.py @@ -62,6 +62,19 @@ def test_absence_is_still_a_supported_signal_not_an_error() -> None: assert key == "schema_0" +def test_the_flat_key_is_the_one_the_transport_resolves() -> None: + """Dispatch and the transport's default path must name the same adapter. + + They are the two callers of resolve_adapter, and a divergence between them + is invisible in a dev workspace where every adapter is installed: it only + appears as an unresolvable key in a real install. + """ + from span_panel_api.adapters import DEFAULT_ADAPTER_KEY + + key, _ = _select_adapter_key(None) + assert key == DEFAULT_ADAPTER_KEY + + def test_missing_adapter_raises_with_the_installed_list() -> None: from span_panel_api.adapters import resolve_adapter diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..4a95325 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,61 @@ +"""Packaging invariants that only bite downstream. + +Nothing in this suite can observe them by importing: a dev workspace resolves +every module from source, where a missing marker file costs nothing. The damage +shows up in someone else's project, against installed wheels, where a fully +annotated distribution silently resolves as Any. +""" + +from __future__ import annotations + +from pathlib import Path +import tomllib + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _wheel_source_packages() -> list[tuple[str, Path]]: + """Every importable package each distribution in the workspace ships. + + Read from the manifests rather than listed here, so an adapter added under + packages/ is covered the day it exists rather than the day someone + remembers to extend this file. + """ + manifests = [_REPO_ROOT / "pyproject.toml", *sorted(_REPO_ROOT.glob("packages/*/pyproject.toml"))] + found: list[tuple[str, Path]] = [] + for manifest in manifests: + config = tomllib.loads(manifest.read_text(encoding="utf-8")) + distribution = config["project"]["name"] + for package in config["tool"]["hatch"]["build"]["targets"]["wheel"]["packages"]: + # src/ layout only. The root distribution also ships scripts/, which + # is tooling rather than an importable API surface consumers type + # against — see the standing note about it being top-level. + if package.startswith("src/"): + found.append((distribution, manifest.parent / package)) + return found + + +def test_the_workspace_has_more_than_one_distribution() -> None: + """Guards the parametrisation below against passing vacuously: if manifest + discovery breaks, every packaging test silently collects nothing.""" + distributions = {name for name, _ in _wheel_source_packages()} + assert distributions == {"span-panel-api", "span-panel-api-schema-0"} + + +@pytest.mark.parametrize( + ("distribution", "package_dir"), + _wheel_source_packages(), + ids=lambda value: value.name if isinstance(value, Path) else str(value), +) +def test_every_shipped_package_carries_a_py_typed_marker(distribution: str, package_dir: Path) -> None: + """PEP 561: without this file a consumer's type checker refuses to read our + annotations and every symbol we export becomes Any on their side. + + This repo type-checks under --strict and avoids Any deliberately; shipping a + distribution that erases all of that at the wheel boundary undoes the work + for exactly the audience it was done for. + """ + marker = package_dir / "py.typed" + assert marker.is_file(), f"{distribution} ships {package_dir.name} without a py.typed marker" From 413e1e7f94f4ff438cb4208e79b383bd44b8c620 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:31:09 -0700 Subject: [PATCH 019/115] docs(release): note the trusted-publisher prerequisite for a new distribution PyPI trusted publishing is configured per project, so span-panel-api-schema-0 needs a pending publisher created before its first release or the publish step fails on an otherwise correct build. --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd505dd..8d7dcd0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,12 @@ on: # release time: the adapter declares a floor on the bootstrap # (span-panel-api>=X), so the committed versions are load-bearing for resolution # and cannot be treated as placeholders that a release stamps over. +# +# Publishing uses PyPI trusted publishing, which is configured per project. A +# distribution released here for the first time needs a pending publisher +# created on PyPI beforehand (project name, this repo, workflow `release.yml`, +# environment `release`); without it the publish step fails on an otherwise +# correct build. jobs: deploy: runs-on: ubuntu-latest From c0608f8d344218ee5ec6ac96ee1ed1aca422823d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:07:43 -0700 Subject: [PATCH 020/115] docs: add a release runbook for the multi-distribution layout The repository publishes more than one PyPI distribution from one source tree, and nothing recorded how that works. Every release would have meant re-deriving it from the workflow. RELEASE.md covers the layout, why the bootstrap and adapters version on separate axes, how a tag selects a distribution and its manifest, the single-distribution and whole-workspace procedures, what has to be set up on PyPI before a new adapter's first release, what each failure message means, and how to verify a release from PyPI rather than from CI. Also fixes the README's install instructions, which still said `pip install span-panel-api` alone. On this branch that produces a client with no parser that connects and then raises SpanPanelAdapterMissingError. --- DEVELOPMENT.md | 22 ++++++ README.md | 9 ++- RELEASE.md | 193 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 RELEASE.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 68dc0d2..d729372 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -70,6 +70,28 @@ To install pre-commit hooks: This installs dependencies (if needed) and configures git pre-commit hooks. +## Workspace layout + +This repository is a uv workspace publishing more than one distribution: the bootstrap (`span-panel-api`, at the root) and one parser package per panel schema (`packages/schema-N/`). `uv sync` installs the workspace, so the test suite runs against every +distribution together. + +To work with the packages individually: + +```bash +# Install the workspace including every member +uv sync --all-packages + +# Build every distribution +uv build --all-packages + +# Build just one +uv build --package span-panel-api-schema-0 +``` + +## Releasing + +See [RELEASE.md](RELEASE.md) — each distribution versions and publishes independently, and the tag name selects which one is published. + ## Contributing 1. Fork and clone the repository diff --git a/README.md b/README.md index 2a95b7a..199f321 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,17 @@ A Python client library for the SPAN Panel v2 API, using MQTT/Homie for real-tim ## Installation +Two packages: the transport, and a parser for your panel's schema. `span-panel-api` contains no parser — installing it alone gives a client that connects and then raises `SpanPanelAdapterMissingError`. + ```bash -pip install span-panel-api +pip install span-panel-api span-panel-api-schema-0 ``` +`span-panel-api-schema-0` parses the flat schema used by firmware `r202603` through `r202627`, which is every panel in the field today. Panels reporting a `data-model-version` need the adapter for that schema major instead; the error names the one it could +not find and lists what is installed. + +Parsers are discovered through the `span_panel_api.schema_adapters` entry-point group, so support for a new panel schema arrives by installing a package rather than by upgrading the transport. The two version independently — see [RELEASE.md](RELEASE.md). + ### Dependencies - `httpx` — v2 authentication and detection endpoints diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..0d11a33 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,193 @@ +# Releasing + +This repository publishes **more than one PyPI distribution** from a single source tree. That makes releasing less obvious than `git tag && push`, so this document is the reference: what lives where, how a tag selects what gets published, and what an +administrator has to do to release everything. + +## Layout + +One repository, one [uv workspace](https://docs.astral.sh/uv/concepts/projects/workspaces/), independent distributions: + +| Distribution | Directory | Manifest | Purpose | +| ------------------------- | -------------------- | ---------------------------------- | ----------------------------------------------------------------------- | +| `span-panel-api` | repository root | `pyproject.toml` | The **bootstrap** — transport, dispatch, protocols. Contains no parser. | +| `span-panel-api-schema-0` | `packages/schema-0/` | `packages/schema-0/pyproject.toml` | Flat-schema parser (firmware `r202603`–`r202627`) | + +Adapters are discovered at runtime through the `span_panel_api.schema_adapters` entry-point group. The bootstrap never imports an adapter, and adding an adapter to the field is an install, not an upgrade. Future adapters follow the same pattern under +`packages/schema-N/`. + +Consequences for releasing: + +- **Each distribution has its own version number** in its own manifest. +- **Each distribution is its own PyPI project**, with its own trusted publisher. +- **A release publishes exactly one distribution.** Releasing "the repo" means cutting one release per distribution. + +## Two version axes + +The bootstrap and the adapters do not share a version, and this is deliberate rather than an oversight. + +- **The bootstrap** versions on its own library API — the transport and the `SchemaAdapter` protocol. +- **An adapter** versions on _its_ library API. The wire format it parses is fixed and is declared by `SUPPORTS_DATA_MODEL_VERSIONS`, not by the version number. A release of `span-panel-api-schema-0` means the parser changed, never that the panel did. + +So `span-panel-api 3.0.0` and `span-panel-api-schema-0 1.0.0` are unrelated numbers, and either can move without the other. + +Adapters declare a floor on the bootstrap (`span-panel-api>=3.0.0b1,<4.0`). That dependency is why the versions committed in the manifests are load-bearing: they participate in resolution, so they are not placeholders that a release process may overwrite. + +## How a tag selects a distribution + +`.github/workflows/release.yml` runs on `release: published` and derives everything from the tag name. There is no lookup table — the manifest path is computed by convention: + +| Tag | Distribution published | Manifest read | +| ----------------- | ------------------------- | ---------------------------------- | +| `vX.Y.Z` | `span-panel-api` | `pyproject.toml` | +| `schema-N-vX.Y.Z` | `span-panel-api-schema-N` | `packages/schema-N/pyproject.toml` | + +Worked example for `schema-0-v1.0.0b1`: + +```text +TAG = schema-0-v1.0.0b1 +${TAG#schema-} → 0-v1.0.0b1 strip leading "schema-" +${SCHEMA%%-v*} → 0 strip trailing "-v…" ⇒ schema number +PACKAGE = span-panel-api-schema-0 +MANIFEST = packages/schema-0/pyproject.toml +VERSION = ${TAG#schema-0-v} → 1.0.0b1 +``` + +Because the schema number is _extracted_ rather than enumerated, a future `schema-1-v0.1.0` resolves to `packages/schema-1/pyproject.toml` with no change to the workflow. + +A tag matching neither form (`1.2.3`, `nightly`) fails immediately with a message naming both accepted forms. + +## The tag does not set the version + +The workflow **verifies** the version; it does not write it. + +```text +tag schema-0-v1.0.0b1 + ⇒ packages/schema-0/pyproject.toml must declare version = "1.0.0b1" + ⇒ otherwise the job fails without publishing +``` + +This means the release ritual is **bump, commit, then tag** — never tag-and-let-CI-stamp. Earlier versions of this workflow rewrote the version from the tag with `sed`, which cannot work here: there is no single manifest to stamp, and the adapter's +dependency floor on the bootstrap means a stamped version could silently disagree with what resolution actually uses. + +A mismatch is a hard failure with both numbers in the message, so the common mistake — tagging before committing the bump — is caught before anything reaches PyPI. + +## Releasing one distribution + +1. **Bump the version** in that distribution's manifest, and add a `CHANGELOG.md` entry (the root one for the bootstrap, `packages/schema-N/CHANGELOG.md` for an adapter). +2. **Merge to `develop`** (or `main`, once this work is no longer prototype) and let CI go green. +3. **Create a GitHub Release:** + - **Tag** — `vX.Y.Z` or `schema-N-vX.Y.Z`, per the table above. + - **Target** — the branch holding the bump. This defaults to the repository's default branch, which is the easiest thing to get wrong; a tag cut from the wrong branch builds the wrong version and fails the verification step. + - **Set as a pre-release** — tick this for any `aN` / `bN` / `rcN` version. +4. **Watch the run.** `gh run watch "$(gh run list --workflow=release.yml --limit 1 --json databaseId -q '.[0].databaseId')"` + +The job prints exactly what it resolved, which is the first thing to read if something looks wrong: + +```text +Tag 'schema-0-v1.0.0b1' releases span-panel-api-schema-0 1.0.0b1 from packages/schema-0/pyproject.toml +Version 1.0.0b1 confirmed. +``` + +## Releasing every distribution + +There is no "release everything" button, and that is intentional — the distributions version independently, so a coordinated release is a sequence of single-distribution releases rather than one action. + +To release the whole workspace: + +1. Bump every manifest that changed, in one branch, with its changelog entry. +2. If the bootstrap's version moved and adapters need the new floor, update `span-panel-api>=…` in each adapter manifest **in the same branch**. Do not release an adapter whose floor points at a bootstrap version that is not yet on PyPI. +3. Merge and let CI go green. +4. Cut the releases **bootstrap first, then each adapter**: + + ```text + v3.0.0b1 → span-panel-api + schema-0-v1.0.0b1 → span-panel-api-schema-0 + schema-1-v0.1.0 → span-panel-api-schema-1 + ``` + + PyPI accepts them in any order, but bootstrap-first means there is never a window in which an adapter is installable and its dependency is not. + +5. Verify from PyPI rather than from CI — see below. + +Only bump and release what actually changed. A distribution with no changes does not need a release just because a sibling had one. + +## Adding a new adapter + +When `packages/schema-N/` lands, the workflow needs no edit — but PyPI does, and this is the step that will be forgotten: + +1. **Create the PyPI project and its trusted publisher before the first release.** Because the project does not exist yet, this is a _pending publisher_, added from account/organization publishing settings rather than from the (non-existent) project page: + + | Field | Value | + | ----------------- | ------------------------- | + | PyPI Project Name | `span-panel-api-schema-N` | + | Owner | `SpanPanel` | + | Repository name | `span-panel-api` | + | Workflow name | `release.yml` | + | Environment name | `release` | + + Every field except the project name is identical across all distributions here, since they all publish from the same repository and workflow. Once the project exists, the same entry is visible and editable at + `https://pypi.org/manage/project//settings/publishing/`. + +2. **Add the package to the workspace** — it is matched by `members = ["packages/*"]` automatically, but the root `[tool.uv.sources]` and the dev dependency group need an entry if the test suite is to exercise it. +3. **Ship a `py.typed` marker** in the new package. CI fails the build without it. + +Trusted publishing verifies repository, workflow filename, and environment — it cannot distinguish _which_ distribution a run is building. That is inherent to a monorepo, and it is why the workflow builds only the tagged package: `dist/` never contains a +sibling that could be uploaded by accident. + +## What the workflow checks + +In order, all before anything is uploaded: + +1. **Tag names a known distribution** — otherwise fail, naming both accepted forms. +2. **The derived manifest exists** — catches a `schema-N` tag with no matching directory. +3. **The tag version equals the committed version** — read with `tomllib`, compared exactly. +4. **Only the tagged package is built** — `uv build --package `, so `dist/` holds exactly one distribution. +5. **Every built wheel ships `py.typed`** — a fully annotated distribution that omits it resolves as `Any` for every downstream consumer, silently undoing the strict typing this repository maintains. + +| Failure | Meaning | +| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `Tag '…' names no distribution` | Tag is malformed. Use `vX.Y.Z` or `schema-N-vX.Y.Z`. | +| `resolves to '…', which does not exist` | Tag names a schema whose directory is not in this commit — usually a tag cut from the wrong branch. | +| `declares version 'A' but the tag says 'B'` | The bump was not committed, or the release targets the wrong branch. | +| `ships no py.typed marker` | The new package is missing the marker file. | +| OIDC / trusted publishing rejection | The PyPI publisher for that project is missing or does not match. Nothing was uploaded; fix and re-run the job. | + +A failed release is safe. Every check runs before upload, so a failure means nothing reached PyPI and the same tag can be re-run once the cause is fixed. + +## Verifying a release + +CI going green proves the build, not the install. The seam this repository is built around — a bootstrap that finds a parser it never imports — can only be exercised across a real package boundary, so verify from PyPI: + +```bash +# 1. The bootstrap alone must fail by name, not with ModuleNotFoundError +python3 -m venv .solo && ./.solo/bin/pip install --pre span-panel-api +./.solo/bin/python -c " +from span_panel_api.adapters import discover_adapters, resolve_adapter, DEFAULT_ADAPTER_KEY +from span_panel_api.exceptions import SpanPanelAdapterMissingError +print('adapters:', sorted(discover_adapters())) +try: + resolve_adapter(DEFAULT_ADAPTER_KEY, 'release check') +except SpanPanelAdapterMissingError as exc: + print('raised as designed:', exc.needed, exc.available) +" + +# 2. Both packages: the adapter resolves through discovery +python3 -m venv .both && ./.both/bin/pip install --pre span-panel-api span-panel-api-schema-0 +./.both/bin/python -c " +from span_panel_api.adapters import discover_adapters +print('adapters:', sorted(discover_adapters())) +" +``` + +Expected: `adapters: []` then a named `SpanPanelAdapterMissingError` in the first, `adapters: ['schema_0']` in the second. + +Drop `--pre` once the versions being verified are not pre-releases. + +## Pre-releases + +Versions like `3.0.0b1` are pre-releases in both places that matter: + +- **PyPI** will not install them without `--pre`, so `pip install span-panel-api` continues to resolve the last stable release. +- **GitHub** should have "Set as a pre-release" ticked, which keeps them out of the repository's "Latest release" slot. + +The publish workflow itself does not care — `on: release: published` fires either way. From 2bd88312d936881785e2d8c3a7df682af0b3abb8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:14:28 -0700 Subject: [PATCH 021/115] feat: dispatch on the panel's real data-model-version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard that refuses a parent/child panel was written, tested, and never invoked: create_span_client hardcoded data_model_version = None, so every panel resolved to the flat parser no matter what it reported. A v1.0 panel did not fail cleanly either — the flat parser reached for energy.ebus.device.circuit/space, which parent/child firmware keeps under deviceClasses, and the run died on "Schema missing 'energy.ebus.device.circuit/space' property": a complaint about a missing property, for a panel whose actual problem is that nothing installed can parse it. The Homie schema is now fetched over REST before the broker is opened, and its dataModelVersion selects the adapter. SPAN confirmed the absence of that field on this endpoint is a reliable flat-versus-parent/child signal, mirroring MQTT's info/data-model-version, and that dispatching on it before opening MQTT is supported. A 1.0 panel now raises SpanPanelAdapterMissingError naming the adapter to install. Dispatch also moved to wherever a parser is built, not just the factory path. A directly constructed SpanMqttClient — which the README documents and the integration uses — previously always resolved the flat adapter, carrying the same defect the factory had. The protocol changes shape once, here, because this is the release that breaks it: - __init__ takes the schema rather than panel_size. Deriving panel_size means reading a block only the flat schema has, so the bootstrap had to understand a wire format it is meant to know nothing about, and an adapter shaped differently had no way to say so. - build_field_metadata() takes no arguments; the adapter holds its schema. Tier 1 dispatch moved to span_panel_api.dispatch so the transport can reach it without importing the factory. adapters.py still answers "what is installed"; dispatch.py answers "what does this panel need". Also pins the enum-tolerance rule that schema_1 inherits: v1.0 requires consumers not to raise on an unrecognised value in a $format-extended enum, which is the opposite of the version rule one import away. The difference is blast radius — an unknown enum member affects one property, an unknown schema version means the whole tree may be misread. 438 tests pass, coverage 94%. --- CHANGELOG.md | 19 +++ packages/schema-0/CHANGELOG.md | 10 ++ .../src/span_panel_api_schema_0/adapter.py | 16 ++- src/span_panel_api/auth.py | 9 ++ src/span_panel_api/dispatch.py | 74 ++++++++++ src/span_panel_api/factory.py | 73 ++-------- src/span_panel_api/models.py | 8 ++ src/span_panel_api/mqtt/client.py | 82 +++++++---- src/span_panel_api/protocol.py | 22 ++- tests/conftest.py | 46 +++++-- tests/test_adapters_discovery.py | 8 +- tests/test_detection_auth.py | 48 +++++++ tests/test_factory_dispatch.py | 129 ++++++++++++++++-- tests/test_mqtt_client_connection.py | 29 ++-- tests/test_mqtt_connect_flow.py | 6 +- tests/test_mqtt_homie.py | 14 +- tests/test_protocol_conformance.py | 2 +- tests/test_schema_zero_adapter.py | 4 +- 18 files changed, 451 insertions(+), 148 deletions(-) create mode 100644 src/span_panel_api/dispatch.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c4df4ba..083b019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **`data-model-version` dispatch is live.** The factory hardcoded `None`, so the guard that refuses a parent/child panel was written, tested and never invoked — every panel resolved to the flat parser regardless of what it reported. The Homie schema is + now fetched over REST **before** the broker is opened and the version drives adapter selection, which SPAN confirmed is a reliable flat-versus-parent/child signal on that endpoint. A `1.0` panel now raises `SpanPanelAdapterMissingError` naming the + adapter to install, instead of dying inside the flat parser on a missing `energy.ebus.device.circuit/space` property. +- **A directly constructed `SpanMqttClient` dispatches too.** Building a client without `create_span_client` previously always resolved the flat adapter, so it carried the same defect the factory path had. Dispatch now happens wherever a parser is built, + and fills in `data_model_version` / `schema_dispatch_reason` rather than leaving them reading `"not dispatched"`. + +### Changed + +- **BREAKING: `SchemaAdapter.__init__` takes the schema, not a panel size.** `adapter_cls(serial_number, schema)` replaces `adapter_cls(serial_number, panel_size)`. `panel_size` is read out of a block only the flat schema has, so the bootstrap had to + understand a wire format it is meant to know nothing about, and an adapter whose schema is shaped differently had no way to say so. Each adapter now reads what its own format defines. +- **BREAKING: `SchemaAdapter.build_field_metadata()` takes no arguments.** It previously received `schema.types` — again a flat-shaped parameter on a format-agnostic protocol. The adapter holds the schema it was constructed with. +- **`V2HomieSchema.data_model_version`** carries the `dataModelVersion` field, `None` when the panel omits it. Absence is the flat signal and stays distinct from an empty string. +- **Tier 1 dispatch moved to `span_panel_api.dispatch.select_adapter_key`** from the private `factory._select_adapter_key`, so the transport can dispatch without importing the factory. `adapters.py` continues to answer "what is installed"; the new module + answers "what does this panel need". + ## [3.0.0b1] - 08/2026 Pre-release. `span-panel-api` becomes a transport and a dispatcher that contains **no parser**. Wire formats ship as separate distributions and register themselves via entry points, so support for a new panel schema arrives by installing a package rather diff --git a/packages/schema-0/CHANGELOG.md b/packages/schema-0/CHANGELOG.md index e3ef55e..08f2ff5 100644 --- a/packages/schema-0/CHANGELOG.md +++ b/packages/schema-0/CHANGELOG.md @@ -7,6 +7,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is fixed — the flat single-device schema, SPAN firmware `r202603` through `r202627` — and is identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version number. A release here means this parser changed, never that the panel did. +## [Unreleased] + +### Changed + +- **BREAKING: `SchemaZeroAdapter(serial_number, schema)`** replaces `SchemaZeroAdapter(serial_number, panel_size)`, following the protocol change in `span-panel-api`. Panel size is now derived here, by reading the circuit `space` format out of the flat + schema's `types` block — knowledge that belongs to this package rather than to the transport, which was previously doing it on every adapter's behalf. +- **`build_field_metadata()` takes no arguments**, reading the schema this adapter was constructed with. + +Requires `span-panel-api` with the reshaped `SchemaAdapter` protocol; the dependency floor is raised accordingly at release. + ## [1.0.0b1] - 08/2026 Pre-release. First release as a standalone distribution. diff --git a/packages/schema-0/src/span_panel_api_schema_0/adapter.py b/packages/schema-0/src/span_panel_api_schema_0/adapter.py index d3dce43..ce279bf 100644 --- a/packages/schema-0/src/span_panel_api_schema_0/adapter.py +++ b/packages/schema-0/src/span_panel_api_schema_0/adapter.py @@ -16,7 +16,7 @@ from span_panel_api_schema_0.field_metadata import build_field_metadata if TYPE_CHECKING: - from span_panel_api.models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot + from span_panel_api.models import FieldMetadata, SpanPanelSnapshot, V2HomieSchema class SchemaZeroAdapter: @@ -25,10 +25,16 @@ class SchemaZeroAdapter: schema_major = "schema_0" SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = (">=0", "<1.0") - def __init__(self, serial_number: str, panel_size: int) -> None: + def __init__(self, serial_number: str, schema: V2HomieSchema) -> None: self._serial_number = serial_number + # `panel_size` is derived here rather than handed in, because deriving + # it means reading the flat schema's `types` block for the circuit + # `space` format — knowledge that belongs to this package. The + # transport used to do this on every adapter's behalf, which only + # worked while every adapter was this one. + self._schema = schema self._accumulator = HomiePropertyAccumulator(serial_number) - self._consumer = HomieDeviceConsumer(self._accumulator, panel_size) + self._consumer = HomieDeviceConsumer(self._accumulator, schema.panel_size) def topics_to_subscribe(self) -> list[str]: return [WILDCARD_TOPIC_FMT.format(serial=self._serial_number)] @@ -42,8 +48,8 @@ def is_ready(self) -> bool: def build_snapshot(self) -> SpanPanelSnapshot: return self._consumer.build_snapshot() - def build_field_metadata(self, schema_types: HomieSchemaTypes) -> dict[str, FieldMetadata]: - return build_field_metadata(schema_types) + def build_field_metadata(self) -> dict[str, FieldMetadata]: + return build_field_metadata(self._schema.types) def circuit_nodes_missing_names(self) -> list[str]: return self._consumer.circuit_nodes_missing_names() diff --git a/src/span_panel_api/auth.py b/src/span_panel_api/auth.py index 4df1a89..ffee86a 100644 --- a/src/span_panel_api/auth.py +++ b/src/span_panel_api/auth.py @@ -206,10 +206,19 @@ async def get_homie_schema( types_json = json.dumps(data.get("types", {}), sort_keys=True) schema_hash = "sha256:" + hashlib.sha256(types_json.encode()).hexdigest()[:16] + # Read before anything else interprets the payload. A parent/child response + # carries `deviceClasses` where this one reads `types`, so every field below + # degrades to empty for such a panel — which is harmless only because this + # value routes it to a different parser before those fields are used. + # Absence is the flat signal and must stay distinct from an empty string. + raw_data_model_version = data.get("dataModelVersion") + data_model_version = None if raw_data_model_version is None else str(raw_data_model_version) + return V2HomieSchema( firmware_version=str(data.get("firmwareVersion", "")), types_schema_hash=schema_hash, types=types, + data_model_version=data_model_version, ) diff --git a/src/span_panel_api/dispatch.py b/src/span_panel_api/dispatch.py new file mode 100644 index 0000000..72a8b66 --- /dev/null +++ b/src/span_panel_api/dispatch.py @@ -0,0 +1,74 @@ +"""Tier 1 dispatch: a panel's data-model-version selects the adapter major. + +Separate from ``adapters.py`` because they answer different questions. +``adapters.py`` knows *what is installed*; this module knows *what this panel +needs*. Keeping them apart is also what lets both the factory and the transport +dispatch without importing each other. +""" + +from __future__ import annotations + +import logging +import re + +from .adapters import DEFAULT_ADAPTER_KEY +from .exceptions import SpanPanelSchemaVersionError + +_LOGGER = logging.getLogger(__name__) + +# The canonical form the published spec defines: MAJOR.MINOR[.PATCH]. +_DMV_CANONICAL = re.compile(r"^(\d+)\.\d+(?:\.\d+)?$") +# Tolerant form: a leading integer major, optionally followed by a separator and +# anything at all. Accepts '1', '1.0.3-rc2', '1_0'; rejects 'v1.0', '', 'x'. +_DMV_MAJOR = re.compile(r"^(\d+)(?:[._-].*)?$") + + +def select_adapter_key(data_model_version: str | None) -> tuple[str, str]: + """Return the adapter key this panel needs, and why. + + Absence is the flat-schema signal — the property was introduced by the same + firmware that introduced the parent/child model, so a panel that does not + publish it is speaking the flat schema. SPAN confirmed this holds over REST + as well as MQTT, which is what makes dispatch possible before the broker is + opened. + + Presence is never read as flat. Falling back to schema_0 for a value we do + not recognise would hand a parent/child panel to the flat parser, which does + not fail — it produces plausible but wrong power and energy figures. A wrong + number in Home Assistant is worse than an error, so anything present and + unreadable raises instead. + + Between those two poles sits a value whose major is unambiguous even though + its full form is not canonical ('1', '1.0-beta'). That is not a guess: the + major is what selects the adapter, and it was read, not assumed. Those + dispatch normally and log the deviation, so a firmware that starts emitting + a new format is visible before it is an outage. + + Note this is the opposite of the rule for enum *properties*, where the spec + requires consumers not to raise on an unrecognised value. The difference is + blast radius: an unknown enum value affects one property, while an unknown + schema version means every value in the tree may be misread. + + Raises: + SpanPanelSchemaVersionError: A version is present but no major can be + extracted from it. + """ + if data_model_version is None: + return DEFAULT_ADAPTER_KEY, "data-model-version absent (flat schema)" + + if (match := _DMV_CANONICAL.match(data_model_version)) is not None: + return f"schema_{int(match.group(1))}", f"data-model-version={data_model_version!r}" + + if (match := _DMV_MAJOR.match(data_model_version)) is not None: + _LOGGER.warning( + "data-model-version=%r is not the canonical MAJOR.MINOR[.PATCH] form; " + "dispatching on major %s. Please report this value.", + data_model_version, + match.group(1), + ) + return ( + f"schema_{int(match.group(1))}", + f"data-model-version={data_model_version!r} (non-canonical; major only)", + ) + + raise SpanPanelSchemaVersionError(data_model_version) diff --git a/src/span_panel_api/factory.py b/src/span_panel_api/factory.py index dd066d1..50cbb46 100644 --- a/src/span_panel_api/factory.py +++ b/src/span_panel_api/factory.py @@ -7,12 +7,12 @@ from __future__ import annotations import logging -import re -from .adapters import DEFAULT_ADAPTER_KEY, resolve_adapter -from .auth import register_v2 +from .adapters import resolve_adapter +from .auth import get_homie_schema, register_v2 from .detection import detect_api_version -from .exceptions import SpanPanelAuthError, SpanPanelSchemaVersionError +from .dispatch import select_adapter_key +from .exceptions import SpanPanelAuthError from .mqtt.client import SpanMqttClient from .mqtt.models import MqttClientConfig @@ -20,56 +20,6 @@ _V2_CLIENT_NAME = "span-panel-api" -# The canonical form the published spec defines: MAJOR.MINOR[.PATCH]. -_DMV_CANONICAL = re.compile(r"^(\d+)\.\d+(?:\.\d+)?$") -# Tolerant form: a leading integer major, optionally followed by a separator and -# anything at all. Accepts '1', '1.0.3-rc2', '1_0'; rejects 'v1.0', '', 'x'. -_DMV_MAJOR = re.compile(r"^(\d+)(?:[._-].*)?$") - - -def _select_adapter_key(data_model_version: str | None) -> tuple[str, str]: - """Tier 1 dispatch: the panel's data-model-version selects the adapter major. - - Absence is the flat-schema signal — the property was introduced by the same - firmware that introduced the parent/child model, so a panel that does not - publish it is speaking the flat schema. - - Presence is never read as flat. Falling back to schema_0 for a value we do - not recognise would hand a parent/child panel to the flat parser, which does - not fail — it produces plausible but wrong power and energy figures. A wrong - number in Home Assistant is worse than an error, so anything present and - unreadable raises instead. - - Between those two poles sits a value whose major is unambiguous even though - its full form is not canonical ('1', '1.0-beta'). That is not a guess: the - major is what selects the adapter, and it was read, not assumed. Those - dispatch normally and log the deviation, so a firmware that starts emitting - a new format is visible before it is an outage. - - Raises: - SpanPanelSchemaVersionError: A version is present but no major can be - extracted from it. - """ - if data_model_version is None: - return DEFAULT_ADAPTER_KEY, "data-model-version absent (flat schema)" - - if (match := _DMV_CANONICAL.match(data_model_version)) is not None: - return f"schema_{int(match.group(1))}", f"data-model-version={data_model_version!r}" - - if (match := _DMV_MAJOR.match(data_model_version)) is not None: - _LOGGER.warning( - "data-model-version=%r is not the canonical MAJOR.MINOR[.PATCH] form; " - "dispatching on major %s. Please report this value.", - data_model_version, - match.group(1), - ) - return ( - f"schema_{int(match.group(1))}", - f"data-model-version={data_model_version!r} (non-canonical; major only)", - ) - - raise SpanPanelSchemaVersionError(data_model_version) - async def create_span_client( host: str, @@ -124,11 +74,13 @@ async def create_span_client( if serial_number is None: raise SpanPanelAuthError("serial_number is required for MQTT transport but could not be determined") - # Phase 0: the factory does not fetch the Homie schema, so no panel can - # report a data-model-version yet. `None` is the correct observation for - # every panel currently in the field — Phase 1 adds the fetch. - data_model_version: str | None = None - adapter_key, dispatch_reason = _select_adapter_key(data_model_version) + # Dispatch reads the schema over REST before the broker is opened. SPAN + # confirmed the absence of `dataModelVersion` on this endpoint is a reliable + # flat-versus-parent/child signal, mirroring MQTT's `info/data-model-version` + # — so the parser is chosen before a single message is consumed, rather than + # a wrong parser being discovered by its output. + schema = await get_homie_schema(host, port=port) + adapter_key, dispatch_reason = select_adapter_key(schema.data_model_version) adapter_cls = resolve_adapter(adapter_key, dispatch_reason) client = SpanMqttClient( @@ -137,8 +89,9 @@ async def create_span_client( mqtt_config, panel_http_port=port, adapter_factory=adapter_cls, - data_model_version=data_model_version, + data_model_version=schema.data_model_version, schema_dispatch_reason=dispatch_reason, + schema=schema, ) await client.connect() return client diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 03d8368..84dd76b 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -143,6 +143,14 @@ class V2HomieSchema: firmware_version: str types_schema_hash: str # SHA-256, first 16 hex chars types: HomieSchemaTypes + # The flat-vs-parent/child discriminator, and the reason this endpoint is + # fetched before MQTT is opened rather than during connect(). Absent on flat + # firmware (r202603-r202627) and present from r202633, which SPAN confirmed + # is a reliable signal over REST — the same one MQTT publishes as + # ``info/data-model-version``. Defaulted so a caller constructing this model + # directly still describes a flat panel, which is what every panel in the + # field is today. + data_model_version: str | None = None @property def panel_size(self) -> int: diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 9e418eb..4caadd9 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -16,10 +16,11 @@ from span_panel_api.schema_drift import log_schema_drift -from ..adapters import DEFAULT_ADAPTER_KEY, discover_adapters, resolve_adapter +from ..adapters import discover_adapters, resolve_adapter from ..auth import get_homie_schema +from ..dispatch import select_adapter_key from ..exceptions import SpanPanelConnectionError, SpanPanelServerError, SpanPanelStaleDataError -from ..models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot +from ..models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot, V2HomieSchema from ..protocol import PanelCapability, SchemaAdapter from .connection import AsyncMqttBridge from .const import MQTT_READY_TIMEOUT_S @@ -43,9 +44,10 @@ def __init__( broker_config: MqttClientConfig, snapshot_interval: float = 1.0, panel_http_port: int = 80, - adapter_factory: Callable[[str, int], SchemaAdapter] | None = None, + adapter_factory: Callable[[str, V2HomieSchema], SchemaAdapter] | None = None, data_model_version: str | None = None, schema_dispatch_reason: str | None = None, + schema: V2HomieSchema | None = None, ) -> None: self._host = host self._serial_number = serial_number @@ -67,23 +69,33 @@ def __init__( self._field_metadata: dict[str, FieldMetadata] | None = None self._schema_hash: str | None = None self._previous_schema_types: HomieSchemaTypes | None = None - # Cached at connect() so the pre-rebuild hook can reconstruct the - # Homie accumulator with the same panel size after a transport-level - # rebuild. Schema cannot change within a session, so caching is safe. - self._panel_size: int | None = None - # Diagnostics, passed in by create_span_client so they are true from the - # first moment the object exists. Constructing directly leaves them - # describing exactly that: a client that never went through dispatch. + # Supplied by create_span_client, which already fetched it to dispatch + # on; None when constructed directly, in which case connect() fetches. + # Either way it is cached for the pre-rebuild hook, which rebuilds the + # parser after a transport-level rebuild. A panel cannot change schema + # within a session, so caching is safe. + self._schema = schema + # Diagnostics. create_span_client passes these so they are true from the + # first moment the object exists; constructing directly leaves them + # describing a client that has not dispatched yet, which connect() then + # fills in once it has a schema to dispatch on. self._data_model_version = data_model_version self._schema_dispatch_reason = schema_dispatch_reason or "not dispatched" - def _build_adapter(self, panel_size: int) -> SchemaAdapter: + def _build_adapter(self, schema: V2HomieSchema) -> SchemaAdapter: """Construct the parser for this session. Called from connect() and from the reconnect path — the only two places a parser is built today. - Resolving the default here rather than in ``__init__`` is deliberate: + With no injected factory this dispatches on the schema rather than + assuming the flat adapter. That matters because a client can be built + directly, bypassing create_span_client: before, such a client handed a + parent/child panel to the flat parser, which does not fail — it reports + plausible and wrong figures. Dispatch now happens on whichever path a + parser is built, so there is one answer rather than two. + + Resolving the adapter here rather than in ``__init__`` is deliberate: constructing a client must not require an adapter to be installed, only building a parser must. That keeps ``import span_panel_api.mqtt.client`` working in an adapter-less install — the configuration entry-point @@ -91,13 +103,18 @@ def _build_adapter(self, panel_size: int) -> SchemaAdapter: is actionable. Raises: + SpanPanelSchemaVersionError: The panel reports a data-model-version + whose schema major cannot be determined. SpanPanelAdapterMissingError: No adapter_factory was supplied and no - package registers the default adapter key. + installed package registers the key this panel needs. """ factory = self._adapter_factory if factory is None: - factory = resolve_adapter(DEFAULT_ADAPTER_KEY, "no adapter_factory supplied to SpanMqttClient") - self._adapter = factory(self._serial_number, panel_size) + adapter_key, dispatch_reason = select_adapter_key(schema.data_model_version) + self._data_model_version = schema.data_model_version + self._schema_dispatch_reason = dispatch_reason + factory = resolve_adapter(adapter_key, dispatch_reason) + self._adapter = factory(self._serial_number, schema) return self._adapter @property @@ -180,10 +197,13 @@ async def connect(self) -> None: self._loop = asyncio.get_running_loop() self._ready_event = asyncio.Event() - # Fetch schema to determine panel size and build field metadata - schema = await get_homie_schema(self._host, port=self._panel_http_port) - self._panel_size = schema.panel_size - adapter = self._build_adapter(schema.panel_size) + # create_span_client already fetched this to dispatch on; refetching + # would be a second call to the same unauthenticated endpoint for a + # value that cannot have changed. A directly-constructed client has no + # schema yet, so it fetches here and dispatches in _build_adapter. + schema = self._schema if self._schema is not None else await get_homie_schema(self._host, port=self._panel_http_port) + self._schema = schema + adapter = self._build_adapter(schema) _LOGGER.info( "MQTT adapter selected: %s (span-panel-api %s)\n data-model-version: %r\n reason: %s\n available: %s", @@ -207,8 +227,10 @@ async def connect(self) -> None: self._schema_hash = new_hash self._previous_schema_types = schema.types - # Build transport-agnostic field metadata from schema - self._field_metadata = self._require_adapter().build_field_metadata(schema.types) + # Build transport-agnostic field metadata. The adapter holds the schema + # it was constructed with, so the transport no longer has to pick out + # the block a particular wire format keeps its type definitions in. + self._field_metadata = self._require_adapter().build_field_metadata() _LOGGER.debug( "MQTT: Creating bridge to %s:%s (serial=%s)", @@ -463,14 +485,22 @@ def _on_pre_rebuild(self) -> None: and a refetch would just add cost. If the panel reboots and the schema actually changed, the existing drift-detection log fires on the next session's `connect()`. + + A cached schema is also what makes the rebuild safe to run from a + synchronous callback. ``_build_adapter`` can raise — on an unreadable + version, or on a key nothing provides — but a cached schema means + connect() already dispatched and resolved successfully on this exact + value, so neither can fail here. The guard below is what enforces that: + no schema means connect() never completed, and there is nothing to + rebuild. """ - if self._panel_size is None: - # Pre-rebuild fired before connect() cached the panel size. - # Treat as a no-op — there is no accumulator state to reset - # because connect() never completed. + if self._schema is None: + # Pre-rebuild fired before connect() cached the schema. Treat as a + # no-op — there is no accumulator state to reset because connect() + # never completed. return _LOGGER.debug("Pre-rebuild — resetting Homie accumulator") - self._build_adapter(self._panel_size) + self._build_adapter(self._schema) async def _wait_for_circuit_names(self, timeout: float) -> None: """Wait for all circuit-like nodes to have a ``name`` property. diff --git a/src/span_panel_api/protocol.py b/src/span_panel_api/protocol.py index 2faea5b..4fa2b35 100644 --- a/src/span_panel_api/protocol.py +++ b/src/span_panel_api/protocol.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable if TYPE_CHECKING: - from .models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot + from .models import FieldMetadata, SpanPanelSnapshot, V2HomieSchema class PanelCapability(Flag): @@ -93,20 +93,18 @@ class SchemaAdapter(Protocol): schema_major: str SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] - def __init__(self, serial_number: str, panel_size: int) -> None: + def __init__(self, serial_number: str, schema: V2HomieSchema) -> None: """Construct a parser for one panel session. Declared because construction is part of the contract: the transport resolves an adapter *class* from the entry-point registry and calls it. - Phase 0 typed the seam as ``Callable[[str, int], SchemaAdapter]``, which - left the signature unchecked against implementations; stating it here - puts it back under the type checker. - - ``panel_size`` is a flat-schema concept the transport fetches on the - adapter's behalf, so this signature is the one part of the protocol - expected to change when schema_1 lands — see the Phase 1 follow-ups, - item 2. It is stated rather than hidden precisely so that change is a - visible protocol break rather than a silent runtime TypeError. + + Takes the whole schema rather than anything derived from it. The + previous signature passed ``panel_size``, which the transport extracted + on the adapter's behalf from a block only the flat schema has — so the + bootstrap had to understand a wire format it is supposed to know nothing + about, and any adapter whose schema is shaped differently could not say + so. Each adapter now reads what its own format defines. """ def topics_to_subscribe(self) -> list[str]: ... @@ -117,7 +115,7 @@ def is_ready(self) -> bool: ... def build_snapshot(self) -> SpanPanelSnapshot: ... - def build_field_metadata(self, schema_types: HomieSchemaTypes) -> dict[str, FieldMetadata]: ... + def build_field_metadata(self) -> dict[str, FieldMetadata]: ... def circuit_nodes_missing_names(self) -> list[str]: ... diff --git a/tests/conftest.py b/tests/conftest.py index 725b21f..aab235d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,16 +34,41 @@ def _reset_ssl_cache() -> None: # Minimal Homie description that makes the device "ready" MINIMAL_DESCRIPTION = json.dumps({"nodes": {"core": {"type": TYPE_CORE}}}) -# Mock schema for SpanMqttClient.connect() — panel_size=32 -_MOCK_SCHEMA = V2HomieSchema( - firmware_version="test", - types_schema_hash="sha256:test", - types={ - "energy.ebus.device.circuit": { - "space": {"datatype": "integer", "format": "1:32:1"}, + +def flat_schema(panel_size: int = 32) -> V2HomieSchema: + """A flat-schema REST response declaring ``panel_size`` breaker spaces. + + No ``data_model_version``: absence is exactly what marks a payload as flat, + so this is what dispatch reads to select schema_0. + """ + return V2HomieSchema( + firmware_version="test", + types_schema_hash="sha256:test", + types={ + "energy.ebus.device.circuit": { + "space": {"datatype": "integer", "format": f"1:{panel_size}:1"}, + }, }, - }, -) + ) + + +def parent_child_schema(data_model_version: str = "1.0") -> V2HomieSchema: + """A parent/child REST response, as r202633+ firmware serves it. + + ``types`` is empty because that firmware keeps its definitions under + ``deviceClasses`` — which is exactly why the version has to be read before + anything tries to parse the payload. + """ + return V2HomieSchema( + firmware_version="spanos2/r202633/01", + types_schema_hash="sha256:test", + types={}, + data_model_version=data_model_version, + ) + + +# Mock schema for SpanMqttClient.connect() — panel_size=32, flat. +MOCK_SCHEMA = flat_schema(32) # --------------------------------------------------------------------------- @@ -109,7 +134,8 @@ def _reconnect() -> int: patch("span_panel_api.mqtt.connection.AsyncMQTTClient") as cls, patch("span_panel_api.mqtt.connection.download_ca_cert", return_value="FAKE-PEM"), patch("span_panel_api.mqtt.connection._build_ssl_context", return_value=MagicMock()), - patch("span_panel_api.mqtt.client.get_homie_schema", return_value=_MOCK_SCHEMA), + patch("span_panel_api.mqtt.client.get_homie_schema", return_value=MOCK_SCHEMA), + patch("span_panel_api.factory.get_homie_schema", return_value=MOCK_SCHEMA), ): mock_client = cls.return_value mock_client.connect.side_effect = _connect diff --git a/tests/test_adapters_discovery.py b/tests/test_adapters_discovery.py index 80d3a28..41b3fed 100644 --- a/tests/test_adapters_discovery.py +++ b/tests/test_adapters_discovery.py @@ -10,6 +10,8 @@ from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.models import MqttClientConfig +from conftest import MOCK_SCHEMA + def test_discovers_the_self_registered_schema_zero_adapter() -> None: _reset_adapter_cache() @@ -40,7 +42,7 @@ def test_default_factory_resolves_the_flat_adapter_through_discovery() -> None: _reset_adapter_cache() client = _client() - adapter = client._build_adapter(32) + adapter = client._build_adapter(MOCK_SCHEMA) assert adapter.schema_major == DEFAULT_ADAPTER_KEY assert type(adapter) is discover_adapters()[DEFAULT_ADAPTER_KEY] @@ -61,7 +63,7 @@ def test_building_a_parser_without_any_adapter_raises_by_name() -> None: client = _client() with patch("span_panel_api.adapters._REGISTRY", {}), pytest.raises(SpanPanelAdapterMissingError) as exc: - client._build_adapter(32) + client._build_adapter(MOCK_SCHEMA) assert exc.value.needed == DEFAULT_ADAPTER_KEY assert exc.value.available == [] @@ -74,7 +76,7 @@ def test_an_explicit_factory_bypasses_discovery_entirely() -> None: client = _client(adapter_factory=real_cls) with patch("span_panel_api.adapters.discover_adapters", side_effect=AssertionError("must not be consulted")): - adapter = client._build_adapter(32) + adapter = client._build_adapter(MOCK_SCHEMA) assert type(adapter) is real_cls diff --git a/tests/test_detection_auth.py b/tests/test_detection_auth.py index 8131bb5..5ff9c07 100644 --- a/tests/test_detection_auth.py +++ b/tests/test_detection_auth.py @@ -428,6 +428,54 @@ async def test_parse_schema(self): assert "energy.ebus.device.distribution-enclosure.core" in result.types core_type = result.types["energy.ebus.device.distribution-enclosure.core"] assert "door" in core_type + # Real flat firmware omits dataModelVersion entirely, and that absence + # is what routes the panel to the flat parser. + assert result.data_model_version is None + + @pytest.mark.asyncio + async def test_parent_child_response_carries_its_data_model_version(self): + """The signal dispatch runs on, read over REST before MQTT is opened. + + A parent/child payload keeps its type definitions under `deviceClasses`, + so `types` comes back empty here — harmless precisely because this + version routes the panel away from the parser that would have read it. + """ + schema_json = { + "firmwareVersion": "spanos2/r202633/01", + "dataModelVersion": "1.0", + "homieDomain": "ebus", + "homieVersion": 5, + "deviceClasses": {"energy.ebus.device.panel": {}}, + } + mock_response = _mock_response(200, schema_json) + with patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await get_homie_schema("192.168.65.70") + + assert result.data_model_version == "1.0" + assert result.types == {} + + @pytest.mark.asyncio + async def test_a_non_string_version_is_still_read_not_discarded(self): + """JSON may carry the version unquoted. Coercing beats treating a + present value as absent, which would silently mean "flat".""" + schema_json = {"firmwareVersion": "spanos2/r202633/01", "dataModelVersion": 1.0, "types": {}} + mock_response = _mock_response(200, schema_json) + with patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await get_homie_schema("192.168.65.70") + + assert result.data_model_version == "1.0" @pytest.mark.asyncio async def test_schema_frozen(self): diff --git a/tests/test_factory_dispatch.py b/tests/test_factory_dispatch.py index 5326c84..34f1ecc 100644 --- a/tests/test_factory_dispatch.py +++ b/tests/test_factory_dispatch.py @@ -8,22 +8,22 @@ from span_panel_api_schema_0 import SchemaZeroAdapter from span_panel_api.adapters import _reset_adapter_cache from span_panel_api.exceptions import SpanPanelAdapterMissingError, SpanPanelSchemaVersionError -from span_panel_api.factory import _select_adapter_key +from span_panel_api.dispatch import select_adapter_key from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.models import MqttClientConfig -from conftest import MINIMAL_DESCRIPTION, SERIAL, TOPIC_PREFIX_SERIAL +from conftest import MINIMAL_DESCRIPTION, SERIAL, TOPIC_PREFIX_SERIAL, flat_schema, parent_child_schema def test_absent_data_model_version_selects_schema_zero() -> None: - key, reason = _select_adapter_key(None) + key, reason = select_adapter_key(None) assert key == "schema_0" assert "absent" in reason @pytest.mark.parametrize("dmv", ["1.0", "1.4", "2.0", "1.0.3", "10.2"]) def test_present_data_model_version_requests_a_numbered_adapter(dmv: str) -> None: - key, reason = _select_adapter_key(dmv) + key, reason = select_adapter_key(dmv) assert key == f"schema_{dmv.split('.')[0]}" assert dmv in reason @@ -35,7 +35,7 @@ def test_non_canonical_but_unambiguous_versions_dispatch_on_their_major(dmv: str Refusing these would take a panel offline over a formatting difference; the deviation is logged instead so a new firmware format is visible early. """ - key, reason = _select_adapter_key(dmv) + key, reason = select_adapter_key(dmv) assert key == f"schema_{dmv[0]}" assert "non-canonical" in reason @@ -50,7 +50,7 @@ def test_unreadable_data_model_version_raises_instead_of_assuming_flat(dmv: str) than an error the user can see and report. """ with pytest.raises(SpanPanelSchemaVersionError) as exc: - _select_adapter_key(dmv) + select_adapter_key(dmv) assert exc.value.data_model_version == dmv @@ -58,7 +58,7 @@ def test_unreadable_data_model_version_raises_instead_of_assuming_flat(dmv: str) def test_absence_is_still_a_supported_signal_not_an_error() -> None: """The flat schema predates the property, so absence must stay non-fatal — it is the single most common case in the field today.""" - key, _ = _select_adapter_key(None) + key, _ = select_adapter_key(None) assert key == "schema_0" @@ -71,7 +71,7 @@ def test_the_flat_key_is_the_one_the_transport_resolves() -> None: """ from span_panel_api.adapters import DEFAULT_ADAPTER_KEY - key, _ = _select_adapter_key(None) + key, _ = select_adapter_key(None) assert key == DEFAULT_ADAPTER_KEY @@ -101,7 +101,11 @@ async def test_create_span_client_wires_schema_zero_adapter_and_diagnostics() -> _reset_adapter_cache() config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") - with patch("span_panel_api.factory.SpanMqttClient") as mock_cls: + schema = flat_schema(32) + with ( + patch("span_panel_api.factory.SpanMqttClient") as mock_cls, + patch("span_panel_api.factory.get_homie_schema", return_value=schema) as mock_fetch, + ): mock_client = mock_cls.return_value mock_client.connect = AsyncMock() @@ -111,9 +115,18 @@ async def test_create_span_client_wires_schema_zero_adapter_and_diagnostics() -> serial_number="test-serial", ) + # Dispatch happens before the client exists, so the schema is fetched by the + # factory rather than by connect(). That ordering is the whole fix: the + # adapter cannot be chosen from a value that has not been read yet. + mock_fetch.assert_awaited_once() + assert result is mock_client _, kwargs = mock_cls.call_args assert kwargs["adapter_factory"] is SchemaZeroAdapter + # The fetched schema is handed to the client so connect() does not + # re-request the same unauthenticated endpoint for a value that cannot + # have changed between the two calls. + assert kwargs["schema"] is schema mock_client.connect.assert_awaited_once() # Diagnostics travel through the constructor, so they are true before # connect() rather than patched onto private state afterwards. There is no @@ -158,3 +171,101 @@ async def test_diagnostics_properties_before_and_after_connect(mqtt_client_mock: assert client.schema_dispatch_reason == "data-model-version absent (flat schema)" await client.close() + + +# --------------------------------------------------------------------------- +# Live dispatch — the version is now read, not assumed +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_a_parent_child_panel_is_refused_rather_than_parsed_as_flat() -> None: + """The bug Part A closes. + + Before, `create_span_client` hardcoded `data_model_version = None`, so a + panel reporting `1.0` was handed to the flat parser regardless of what it + said. Reverting the dispatch here shows what that cost: the flat parser + reaches for `energy.ebus.device.circuit/space`, which a parent/child + payload keeps under `deviceClasses`, and the run dies on + + ValueError: Schema missing 'energy.ebus.device.circuit/space' property + + — a message about a missing property, for a panel whose real problem is + that nothing installed can parse it. The panel is now refused by name + instead, naming the adapter to install and what is already there. + """ + from span_panel_api.factory import create_span_client + + _reset_adapter_cache() + config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") + with ( + patch("span_panel_api.factory.get_homie_schema", return_value=parent_child_schema()), + pytest.raises(SpanPanelAdapterMissingError) as exc, + ): + await create_span_client("192.168.1.1", mqtt_config=config, serial_number="test-serial") + + assert exc.value.needed == "schema_1" + assert "schema_0" in exc.value.available + + +@pytest.mark.asyncio +async def test_a_directly_constructed_client_dispatches_too() -> None: + """Building a client directly must not bypass dispatch. + + `create_span_client` is not the only way to get a client — the README + documents direct construction, and the integration uses it. Before, that + path always resolved the flat adapter, so it carried exactly the bug the + factory path just had fixed. Dispatch now happens wherever a parser is + built. + """ + _reset_adapter_cache() + config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") + client = SpanMqttClient("192.168.1.1", SERIAL, config) + with pytest.raises(SpanPanelAdapterMissingError) as exc: + client._build_adapter(parent_child_schema()) + + assert exc.value.needed == "schema_1" + + +def test_dispatch_records_what_it_read_on_the_client() -> None: + """Diagnostics for a directly-constructed client are filled in by dispatch + rather than left saying 'not dispatched' after a parser exists.""" + _reset_adapter_cache() + config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") + client = SpanMqttClient("192.168.1.1", SERIAL, config) + + assert client.schema_dispatch_reason == "not dispatched" + + client._build_adapter(flat_schema(32)) + + assert client.data_model_version is None + assert "absent" in client.schema_dispatch_reason + assert client.schema_major == "schema_0" + + +def test_an_unrecognised_enum_value_is_passed_through_not_raised() -> None: + """The mirror image of the version rule, and deliberately so. + + v1.0 requires consumers not to raise on an unrecognised value in a + `$format`-extended enum: SPAN may add enum members without a major bump, so + raising would take a panel offline over a value the spec allows. Dispatch + takes the opposite line on `data-model-version` because the blast radius + differs — an unknown enum member affects one property, while an unknown + schema version means every value in the tree may be misread. + + Pinned here because both rules live one import apart, and schema_1 inherits + this one. + """ + from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator + from span_panel_api_schema_0.consumer import HomieDeviceConsumer + + accumulator = HomiePropertyAccumulator(SERIAL) + consumer = HomieDeviceConsumer(accumulator, panel_size=32) + + consumer.handle_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) + consumer.handle_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") + # A shed-priority value no released firmware emits today. + consumer.handle_message(f"{TOPIC_PREFIX_SERIAL}/core/shed-priority", "SOME_FUTURE_PRIORITY") + + snapshot = consumer.build_snapshot() + assert snapshot is not None diff --git a/tests/test_mqtt_client_connection.py b/tests/test_mqtt_client_connection.py index acab9e3..cfef7c8 100644 --- a/tests/test_mqtt_client_connection.py +++ b/tests/test_mqtt_client_connection.py @@ -13,6 +13,8 @@ from span_panel_api.mqtt.connection import AsyncMqttBridge from span_panel_api.mqtt.models import MqttClientConfig +from conftest import flat_schema as _schema + def _make_client() -> SpanMqttClient: """Build a SpanMqttClient without I/O for unit testing.""" @@ -436,7 +438,7 @@ def cancel(self) -> None: def test_adapter_is_none_before_connect() -> None: - """The parser needs panel_size, which only connect() knows, so there is no + """The parser needs the schema, which only connect() has, so there is no adapter until then — mirroring today's `self._homie = None`.""" from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.models import MqttClientConfig @@ -467,21 +469,23 @@ def test_client_defaults_to_the_flat_adapter() -> None: ) assert client._adapter_factory is None - assert isinstance(client._build_adapter(40), SchemaZeroAdapter) + assert isinstance(client._build_adapter(_schema(40)), SchemaZeroAdapter) -def test_injected_factory_receives_serial_and_panel_size() -> None: - """The factory must be called with the panel_size discovered at connect, - not a placeholder — panel_size drives unmapped-tab computation.""" +def test_injected_factory_receives_serial_and_schema() -> None: + """The factory must be called with the schema discovered at connect, not a + placeholder — the adapter reads panel size from it, which drives + unmapped-tab computation.""" + from span_panel_api.models import V2HomieSchema from span_panel_api_schema_0 import SchemaZeroAdapter from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.models import MqttClientConfig - seen: list[tuple[str, int]] = [] + seen: list[tuple[str, V2HomieSchema]] = [] - def factory(serial_number: str, panel_size: int) -> SchemaZeroAdapter: - seen.append((serial_number, panel_size)) - return SchemaZeroAdapter(serial_number=serial_number, panel_size=panel_size) + def factory(serial_number: str, schema: V2HomieSchema) -> SchemaZeroAdapter: + seen.append((serial_number, schema)) + return SchemaZeroAdapter(serial_number=serial_number, schema=schema) client = SpanMqttClient( "192.0.2.10", @@ -491,8 +495,9 @@ def factory(serial_number: str, panel_size: int) -> SchemaZeroAdapter: ) # Exercise the construction path directly rather than standing up a broker. - client._panel_size = 40 - client._build_adapter(40) + schema = _schema(40) + client._build_adapter(schema) - assert seen == [("sim-40t-001", 40)] + assert seen == [("sim-40t-001", schema)] + assert seen[0][1].panel_size == 40 assert isinstance(client.adapter, SchemaZeroAdapter) diff --git a/tests/test_mqtt_connect_flow.py b/tests/test_mqtt_connect_flow.py index 6297218..96b2805 100644 --- a/tests/test_mqtt_connect_flow.py +++ b/tests/test_mqtt_connect_flow.py @@ -764,14 +764,14 @@ async def test_pre_rebuild_preserves_schema_state(self, mqtt_client_mock: MagicM schema_hash_before = client._schema_hash schema_types_before = client._previous_schema_types field_metadata_before = client._field_metadata - panel_size_before = client._panel_size + schema_before = client._schema client._on_pre_rebuild() assert client._schema_hash == schema_hash_before assert client._previous_schema_types == schema_types_before assert client._field_metadata == field_metadata_before - assert client._panel_size == panel_size_before + assert client._schema == schema_before await client.close() @@ -780,7 +780,7 @@ async def test_pre_rebuild_before_connect_is_noop(self) -> None: """If pre-rebuild somehow fires before connect() completes, the handler must not raise — there is no accumulator state to reset.""" client = _make_span_client() - # _panel_size is None because connect() never ran. + # _schema is None because connect() never ran. client._on_pre_rebuild() # No exception, no state changes. assert client._adapter is None diff --git a/tests/test_mqtt_homie.py b/tests/test_mqtt_homie.py index ece93ae..19a163f 100644 --- a/tests/test_mqtt_homie.py +++ b/tests/test_mqtt_homie.py @@ -40,6 +40,8 @@ from span_panel_api.mqtt.const import HOMIE_STATE_READY, MQTT_DEFAULT_MQTTS_PORT, MQTT_DEFAULT_WS_PORT, MQTT_DEFAULT_WSS_PORT from span_panel_api.mqtt.connection import AsyncMqttBridge from span_panel_api.mqtt.models import MqttClientConfig + +from conftest import flat_schema from span_panel_api.protocol import ( PanelCapability, ) @@ -1017,7 +1019,7 @@ async def test_set_circuit_relay_publishes(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) - client._adapter = SchemaZeroAdapter(serial_number=SERIAL, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32)) mock_bridge = MagicMock() client._bridge = mock_bridge @@ -1036,7 +1038,7 @@ async def test_set_circuit_priority_publishes(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) - client._adapter = SchemaZeroAdapter(serial_number=SERIAL, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32)) mock_bridge = MagicMock() client._bridge = mock_bridge @@ -1055,7 +1057,7 @@ async def test_set_dominant_power_source_publishes(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) - client._adapter = SchemaZeroAdapter(serial_number=SERIAL, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32)) # Populate the homie description so core node is known desc = _make_description(_core_description()) @@ -1080,7 +1082,7 @@ async def test_set_dominant_power_source_no_core_node_raises(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) - client._adapter = SchemaZeroAdapter(serial_number=SERIAL, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32)) # No description loaded — core node not found with pytest.raises(SpanPanelServerError, match="Core node not found"): @@ -1099,7 +1101,7 @@ async def test_get_snapshot_returns_homie_state(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) - client._adapter = SchemaZeroAdapter(serial_number=SERIAL, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32)) client._bridge = _ConnectedBridge() # Manually ready the adapter @@ -1129,7 +1131,7 @@ async def test_ping_true_when_connected_and_ready(self): mock_bridge = MagicMock() mock_bridge.is_connected.return_value = True client._bridge = mock_bridge - client._adapter = SchemaZeroAdapter(serial_number=SERIAL, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32)) client._adapter.handle_message(f"{PREFIX}/$state", "ready") client._adapter.handle_message(f"{PREFIX}/$description", _make_description(_core_description())) diff --git a/tests/test_protocol_conformance.py b/tests/test_protocol_conformance.py index 48ee23e..5b64e81 100644 --- a/tests/test_protocol_conformance.py +++ b/tests/test_protocol_conformance.py @@ -97,7 +97,7 @@ def test_schema_adapter_construction_signature_matches_its_implementation() -> N declared = list(inspect.signature(SchemaAdapter.__init__).parameters) implemented = list(inspect.signature(SchemaZeroAdapter.__init__).parameters) - assert declared == ["self", "serial_number", "panel_size"] + assert declared == ["self", "serial_number", "schema"] assert implemented == declared, f"SchemaZeroAdapter.__init__{implemented} does not match the protocol {declared}" diff --git a/tests/test_schema_zero_adapter.py b/tests/test_schema_zero_adapter.py index cf35094..48ab729 100644 --- a/tests/test_schema_zero_adapter.py +++ b/tests/test_schema_zero_adapter.py @@ -11,6 +11,8 @@ import pytest from span_panel_api_schema_0 import SchemaZeroAdapter + +from conftest import flat_schema from span_panel_api.protocol import SchemaAdapter SERIAL = "sim-40t-001" @@ -18,7 +20,7 @@ @pytest.fixture def adapter() -> SchemaZeroAdapter: - return SchemaZeroAdapter(serial_number=SERIAL, panel_size=40) + return SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(40)) def test_satisfies_the_protocol(adapter: SchemaZeroAdapter) -> None: From 36a08c515f7a026923e5587d1f1fe24532ba78f8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:18:19 -0700 Subject: [PATCH 022/115] fix: carry the adapter-less acceptance check onto the new signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check builds a parser against a real bootstrap-only wheel, so it still passed a panel size and died on AttributeError before reaching the error it exists to assert. The unit suite could not catch this: it never runs against an install that has no adapter. It now passes a flat schema, which is also the case this check is about — every panel in the field reports no data-model-version, so dispatch asks for the default key and finds nothing providing it. --- scripts/verify_adapterless_install.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/verify_adapterless_install.py b/scripts/verify_adapterless_install.py index f3cff98..912f764 100644 --- a/scripts/verify_adapterless_install.py +++ b/scripts/verify_adapterless_install.py @@ -57,9 +57,20 @@ def main() -> None: ) # 4. Building a parser must raise the named error, not an opaque one, and - # must say which adapter was wanted. + # must say which adapter was wanted. A flat schema is used because that + # is the case a bootstrap-only install is expected to fail on: every + # panel in the field today reports no data-model-version, so dispatch + # asks for the default key and finds nothing providing it. + from span_panel_api.models import V2HomieSchema + + flat_schema = V2HomieSchema( + firmware_version="spanos2/r202603/05", + types_schema_hash="sha256:0000000000000000", + types={"energy.ebus.device.circuit": {"space": {"datatype": "integer", "format": "1:32:1"}}}, + ) + try: - client._build_adapter(32) # pylint: disable=protected-access + client._build_adapter(flat_schema) # pylint: disable=protected-access except SpanPanelAdapterMissingError as exc: if exc.needed != DEFAULT_ADAPTER_KEY: _fail(f"error names adapter {exc.needed!r}, expected {DEFAULT_ADAPTER_KEY!r}") From 3a151a398439b095f702c9b354c02e6e7265189a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:31:39 -0700 Subject: [PATCH 023/115] feat: let the SDK parse the parent/child tree without owning a socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 tasks 1-3. Controller normally holds an MQTT client and subscribes as it walks a tree — the root first, then each child as it announces. A SchemaAdapter cannot work that way: the transport builds the parser at client.py:206 and the connection does not exist until :242, and the parser is never handed one. So a parser has no way to subscribe to anything, let alone to keep subscribing as it discovers children. It turns out not to need one. Controller is given a transport that only records its subscriptions, and the adapter asks for a single broad subscription up front through the existing topics_to_subscribe() — which is exactly what the flat adapter already does with ebus/5/{serial}/#. Messages arrive through handle_message and are routed to whichever SDK callback asked for them. Verified against a real panel_sim tree in the library's own order of operations: parser built with no connection, one wildcard requested, 13 devices and 11 children discovered, root ready, zero connection access from the parser. This also removes the failure mode that made task 3 the risky one. There is no hand-wired resync hook to forget, because the transport re-subscribes the same static list on every reconnect and the broker replays the retained tree. A missed resync would have produced stale readings rather than an error. Task 1 is therefore reverted: with subscriptions never revised and nothing retained, AsyncMqttBridge needs neither unsubscribe() nor a retain parameter, and adding public API with no caller is worse than not adding it. Two things the tests caught. Re-recording a subscription kept the dict key's original insertion position, leaving a re-registered specific pattern behind a broader one in match order. And the ordering rationale was overstated: tree-rooted discovery records four device-scoped patterns per device which cannot overlap, so most-recent-wins is defensive — it matters only for the SDK's wildcard mode, and the docstring now says so. No entry point is registered. Resolving schema_1 to a package that cannot build a snapshot would turn a clean SpanPanelAdapterMissingError into an opaque failure later. It lands with the mapper. --- packages/schema-1/CHANGELOG.md | 16 ++ packages/schema-1/README.md | 8 + packages/schema-1/pyproject.toml | 43 ++++++ .../src/span_panel_api_schema_1/__init__.py | 11 ++ .../src/span_panel_api_schema_1/py.typed | 0 .../src/span_panel_api_schema_1/transport.py | 123 +++++++++++++++ pyproject.toml | 8 +- tests/test_packaging.py | 14 +- tests/test_schema_one_transport.py | 144 ++++++++++++++++++ uv.lock | 42 +++++ 10 files changed, 405 insertions(+), 4 deletions(-) create mode 100644 packages/schema-1/CHANGELOG.md create mode 100644 packages/schema-1/README.md create mode 100644 packages/schema-1/pyproject.toml create mode 100644 packages/schema-1/src/span_panel_api_schema_1/__init__.py create mode 100644 packages/schema-1/src/span_panel_api_schema_1/py.typed create mode 100644 packages/schema-1/src/span_panel_api_schema_1/transport.py create mode 100644 tests/test_schema_one_transport.py diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md new file mode 100644 index 0000000..6e0d8ca --- /dev/null +++ b/packages/schema-1/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to `span-panel-api-schema-1` are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- **`BridgeControllerTransport`** — an `ebus_sdk.MqttControllerTransport` over `AsyncMqttBridge`, so `Controller` parses the parent/child tree over span-panel-api's own connection to the panel's broker. Owns the per-subscription routing table the SDK's + client keeps internally, because our bridge has one message callback for the whole connection. + +### Not yet + +- No `schema_1` entry point is registered. Until this package can build a snapshot, a 1.x panel gets `SpanPanelAdapterMissingError` naming `schema_1` rather than a late failure from a partial parser. diff --git a/packages/schema-1/README.md b/packages/schema-1/README.md new file mode 100644 index 0000000..a872262 --- /dev/null +++ b/packages/schema-1/README.md @@ -0,0 +1,8 @@ +# span-panel-api-schema-1 + +Parent/child schema parser (`data-model-version` 1.x, SPAN firmware r202633+) for [span-panel-api](https://github.com/SpanPanel/span-panel-api). + +**Status: incomplete.** This distribution does not yet register a `schema_1` adapter, so installing it does not make a parent/child panel work. A 1.x panel still raises `SpanPanelAdapterMissingError` naming `schema_1`, which is the honest answer until the +parser can build a snapshot. + +What exists today is `BridgeControllerTransport` — an `ebus_sdk.MqttControllerTransport` backed by span-panel-api's own MQTT connection, so the eBus SDK can parse the parent/child tree while the connection to the panel's broker stays ours. diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml new file mode 100644 index 0000000..f9ec1a6 --- /dev/null +++ b/packages/schema-1/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "span-panel-api-schema-1" +version = "0.1.0b1" +description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api" +authors = [ + {name = "SpanPanel"} +] +readme = "README.md" +license = "MIT" +requires-python = ">=3.10,<4.0" +dependencies = [ + "span-panel-api>=3.0.0b1,<4.0", + # Only this distribution depends on the eBus SDK. The bootstrap and + # schema-0 stay clean, so a flat-panel install never pulls it in — which is + # what bounds the release coupling this dependency introduces to panels on + # r202633+. + "ebus-sdk>=0.17.0,<1.0", +] + +[project.urls] +Homepage = "https://github.com/SpanPanel/span-panel-api" +Issues = "https://github.com/SpanPanel/span-panel-api/issues" + +# NO [project.entry-points."span_panel_api.schema_adapters"] BLOCK YET. +# +# Deliberate, and the reason is the failure mode. Registering `schema_1` makes +# dispatch resolve it for a 1.x panel, which would then fail somewhere inside +# snapshot building — an opaque error, late, on a path the user cannot act on. +# Unregistered, the same panel gets the clean SpanPanelAdapterMissingError that +# Phase 2 Part A built, naming exactly what is missing. +# +# The entry point lands with the snapshot mapper (Phase 2 task 4), when this +# package can answer for a panel end to end. + +[tool.uv.sources] +span-panel-api = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/span_panel_api_schema_1"] diff --git a/packages/schema-1/src/span_panel_api_schema_1/__init__.py b/packages/schema-1/src/span_panel_api_schema_1/__init__.py new file mode 100644 index 0000000..e60e53c --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/__init__.py @@ -0,0 +1,11 @@ +"""Parent/child schema (data-model-version 1.x) support for span-panel-api. + +Work in progress. This package does not yet register a `schema_1` adapter — see +the note in pyproject.toml. Until it can answer for a panel end to end, a 1.x +panel gets a clean SpanPanelAdapterMissingError rather than a late failure from +a half-built parser. +""" + +from span_panel_api_schema_1.transport import ControllerRoutes + +__all__ = ["ControllerRoutes"] diff --git a/packages/schema-1/src/span_panel_api_schema_1/py.typed b/packages/schema-1/src/span_panel_api_schema_1/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/schema-1/src/span_panel_api_schema_1/transport.py b/packages/schema-1/src/span_panel_api_schema_1/transport.py new file mode 100644 index 0000000..43bc62c --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/transport.py @@ -0,0 +1,123 @@ +"""The seam that lets `ebus_sdk.Controller` parse a tree it owns no socket for. + +`Controller` normally holds an MQTT client and subscribes as it walks a device +tree — for the root first, then per child as each announces. A `SchemaAdapter` +cannot work that way: the transport builds the parser *before* the connection +exists and never hands it one, so a parser has no way to subscribe to anything. + +It turns out not to need one. `Controller` is given a transport that only +*records* its subscriptions, and the adapter asks the transport layer for one +broad subscription up front — the same thing the flat adapter does with +``ebus/5/{serial}/#``. Every message then arrives through +``SchemaAdapter.handle_message`` and is routed here to whichever SDK callback +asked for it. + +Two consequences, both load-bearing: + +* **The adapter stays connection-free**, so it works under a protocol that + hands it messages rather than a socket. +* **Reconnect needs no special handling.** The transport re-subscribes the same + static list on every reconnect, the broker replays the retained tree, and the + SDK repopulates from it. There is no hand-wired ``resync`` hook to forget — + which was the failure mode most likely to go unnoticed, because it produces + stale readings rather than an error. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from paho.mqtt.client import topic_matches_sub + +if TYPE_CHECKING: + from collections.abc import Callable + + +class ControllerRoutes: + """Record `Controller`'s subscriptions and route messages back to them. + + Structurally satisfies `ebus_sdk.MqttControllerTransport`. Receive-only by + design — see :meth:`publish`. + """ + + def __init__(self) -> None: + # Insertion-ordered; dispatch walks it in reverse — see dispatch(). + self._routes: dict[str, Callable[[str, bytes], None]] = {} + + # -- MqttControllerTransport ------------------------------------------- + + def publish(self, topic: str, data: str, qos: int = 1, retain: bool = False) -> None: + """Not supported, and deliberately loud about it. + + Commands do not travel this way. `SchemaAdapter` exposes + ``set_circuit_relay_topic`` and friends, and the transport publishes to + the topic it is handed — so an adapter never needs a socket to command a + panel, and neither does this class. + + Raising beats a silent no-op: a dropped command leaves the panel in the + state the user was trying to change, with the UI reporting they changed + it. + """ + raise NotImplementedError( + "ControllerRoutes is receive-only. Publish through the adapter's " + "set_*_topic methods, which the transport layer sends for you." + ) + + def subscribe(self, sub: str, param: Any, qos: int = 1) -> None: # pylint: disable=unused-argument + """Record the callback for `sub`. Nothing reaches the wire. + + `param` is the SDK's name for the callback, and this signature mirrors + `MqttClient.subscribe` exactly — including its `Any` — so a real + `MqttClient` still satisfies the same protocol this class does. + + `qos` is accepted and ignored, which is why it is disabled above rather + than removed: the protocol fixes the signature, and quality of service + is a property of the one wire subscription the transport layer makes on + the adapter's behalf, not of a route recorded in a dict. + """ + # Pop before insert: assigning an existing key updates the value but + # keeps the key's original position, which would leave a re-registered + # pattern behind whatever was added after it in dispatch's match order. + self._routes.pop(sub, None) + self._routes[sub] = param + + def unsubscribe(self, sub: str) -> None: + """Forget the callback for `sub`. + + The broad wire subscription stays. Messages for a device the SDK has + dropped simply stop matching a route, and dispatch discards them. + """ + self._routes.pop(sub, None) + + # -- our side ---------------------------------------------------------- + + def dispatch(self, topic: str, payload: str) -> None: + """Deliver one message to the callback of the route that matches. + + Walked most-recent-first, which is defensive rather than currently + required. Tree-rooted discovery subscribes four **device-scoped** + patterns per device — `$state`, `$description`, `+/+`, `+/+/$target` + (`Controller._subscribe_device_topics`) — which cannot overlap each + other or another device's, so today exactly one route matches any topic. + + Pinned anyway because the SDK's wildcard discovery mode subscribes + `/5/+/$state`, overlapping every per-device `$state`. Under + insertion order that would hand a device's state to the wildcard + handler — a silent misattribution rather than an error. Preferring the + most recently recorded route costs nothing while overlap does not + occur, and is correct if it ever does. + + A topic matching no route is dropped: the wire subscription is broader + than the SDK's interest by construction. + + The SDK hands callbacks `bytes`; the transport hands us `str`. + """ + for sub in reversed(self._routes): + if topic_matches_sub(sub, topic): + self._routes[sub](topic, payload.encode()) + return + + @property + def routes(self) -> tuple[str, ...]: + """The recorded subscription patterns, most recent last. Diagnostics only.""" + return tuple(self._routes) diff --git a/pyproject.toml b/pyproject.toml index 7cf6846..d1909b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dev = [ # remain installable without it. It is here so the test suite exercises the # two distributions together, which is the configuration users will run. "span-panel-api-schema-0", + "span-panel-api-schema-1", "pytest>=9.0.2", "pytest-asyncio>=1.3.0", "pytest-cov", @@ -63,6 +64,7 @@ members = ["packages/*"] [tool.uv.sources] span-panel-api-schema-0 = { workspace = true } +span-panel-api-schema-1 = { workspace = true } [tool.hatch.build.targets.wheel] packages = ["src/span_panel_api", "scripts"] @@ -147,7 +149,11 @@ ignore_missing_imports = true [tool.coverage.run] data_file = ".local_coverage_data" -source = ["src/span_panel_api", "packages/schema-0/src/span_panel_api_schema_0"] +source = [ + "src/span_panel_api", + "packages/schema-0/src/span_panel_api_schema_0", + "packages/schema-1/src/span_panel_api_schema_1", +] omit = [ "tests/*", "*/tests/*", diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 4a95325..e0b597a 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -37,11 +37,19 @@ def _wheel_source_packages() -> list[tuple[str, Path]]: return found -def test_the_workspace_has_more_than_one_distribution() -> None: +def test_every_workspace_member_is_discovered() -> None: """Guards the parametrisation below against passing vacuously: if manifest - discovery breaks, every packaging test silently collects nothing.""" + discovery breaks, every packaging test silently collects nothing. + + Derived from the directories on disk rather than a hardcoded list, so + adding an adapter does not require editing this file — the failure mode + worth catching is discovery finding *fewer* manifests than exist. + """ distributions = {name for name, _ in _wheel_source_packages()} - assert distributions == {"span-panel-api", "span-panel-api-schema-0"} + expected = 1 + len(list(_REPO_ROOT.glob("packages/*/pyproject.toml"))) + + assert "span-panel-api" in distributions + assert len(distributions) == expected, f"discovered {sorted(distributions)}, expected {expected} distributions" @pytest.mark.parametrize( diff --git a/tests/test_schema_one_transport.py b/tests/test_schema_one_transport.py new file mode 100644 index 0000000..b67fd61 --- /dev/null +++ b/tests/test_schema_one_transport.py @@ -0,0 +1,144 @@ +"""The seam that lets `ebus_sdk.Controller` parse a tree it owns no socket for. + +These tests are about routing, not parsing. They pin the behaviour the SDK's own +MQTT client provides internally, which a `SchemaAdapter` cannot rely on because +it is built before any connection exists and never receives one. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from ebus_sdk import MqttControllerTransport + +from span_panel_api_schema_1 import ControllerRoutes + + +def test_it_satisfies_the_sdk_transport_protocol() -> None: + """Structural conformance, checked rather than assumed. + + `MqttControllerTransport` is runtime_checkable and method-only, so this is a + real check — and it is what upstream shipped in 0.17.0 specifically so a + bring-your-own-transport consumer would not need a cast. + """ + assert isinstance(ControllerRoutes(), MqttControllerTransport) + + +def test_it_needs_no_connection_to_construct() -> None: + """The whole point. The transport builds the parser before the connection + exists, so anything the parser owns must be constructible without one.""" + routes = ControllerRoutes() + + assert routes.routes == () + + +def test_subscribe_records_a_route() -> None: + routes = ControllerRoutes() + callback = MagicMock() + + routes.subscribe("ebus/5/panel/#", callback, qos=1) + + assert routes.routes == ("ebus/5/panel/#",) + + +def test_unsubscribe_forgets_the_route() -> None: + """The wire subscription is broader and stays put; messages for a device the + SDK dropped simply stop matching.""" + routes = ControllerRoutes() + callback = MagicMock() + routes.subscribe("ebus/5/child/#", callback) + + routes.unsubscribe("ebus/5/child/#") + + assert routes.routes == () + routes.dispatch("ebus/5/child/meter/active-power", "1.0") + callback.assert_not_called() + + +def test_unsubscribing_something_unknown_is_harmless() -> None: + ControllerRoutes().unsubscribe("ebus/5/never-subscribed/#") # must not raise + + +def test_publish_refuses_rather_than_silently_dropping() -> None: + """Commands do not travel this way — the adapter returns a topic and the + transport layer sends it. A silent no-op here would leave the panel in the + state the user was trying to change, with the UI reporting they changed it. + """ + with pytest.raises(NotImplementedError, match="receive-only"): + ControllerRoutes().publish("ebus/5/panel/core/relay/set", "CLOSED") + + +def test_dispatch_delivers_bytes_to_the_matching_callback() -> None: + """The transport hands us `str`; the SDK hands its callbacks `bytes`.""" + routes = ControllerRoutes() + callback = MagicMock() + routes.subscribe("ebus/5/panel/+/+", callback) + + routes.dispatch("ebus/5/panel/meter/active-power", "-121.0") + + callback.assert_called_once_with("ebus/5/panel/meter/active-power", b"-121.0") + + +def test_a_topic_matching_no_route_is_dropped() -> None: + """Expected, not exceptional: the wire subscription is broader than the + SDK's interest by construction.""" + routes = ControllerRoutes() + callback = MagicMock() + routes.subscribe("ebus/5/panel/#", callback) + + routes.dispatch("ebus/5/other-device/meter/active-power", "1.0") + + callback.assert_not_called() + + +def test_the_most_recently_recorded_matching_route_wins() -> None: + """Defensive rather than currently required: tree-rooted discovery records + four device-scoped patterns per device, which cannot overlap. But the SDK's + wildcard mode subscribes `/5/+/$state`, overlapping every per-device + `$state`. Under insertion order that would hand a device's state to the + wildcard handler — silent misattribution, not an error.""" + routes = ControllerRoutes() + broad = MagicMock(name="root") + narrow = MagicMock(name="child") + routes.subscribe("ebus/5/#", broad) + routes.subscribe("ebus/5/child-a/#", narrow) + + routes.dispatch("ebus/5/child-a/meter/active-power", "-3500.0") + + narrow.assert_called_once() + broad.assert_not_called() + + +def test_a_topic_only_the_broad_route_covers_still_arrives() -> None: + """The corollary: preferring the specific must not strand the general.""" + routes = ControllerRoutes() + broad = MagicMock(name="root") + narrow = MagicMock(name="child") + routes.subscribe("ebus/5/#", broad) + routes.subscribe("ebus/5/child-a/#", narrow) + + routes.dispatch("ebus/5/panel/$state", "ready") + + broad.assert_called_once() + narrow.assert_not_called() + + +def test_rerecording_a_pattern_replaces_it_and_moves_it_to_most_recent() -> None: + """Re-registering must not leave the stale callback ahead in match order. + + Caught by this test in review: assigning an existing dict key updates the + value but keeps the key's original position. + """ + routes = ControllerRoutes() + first = MagicMock(name="first") + second = MagicMock(name="second") + routes.subscribe("ebus/5/child-a/#", first) + routes.subscribe("ebus/5/#", MagicMock(name="root")) + routes.subscribe("ebus/5/child-a/#", second) + + routes.dispatch("ebus/5/child-a/$state", "ready") + + second.assert_called_once() + first.assert_not_called() diff --git a/uv.lock b/uv.lock index 9c10a96..2fe9c2e 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,7 @@ resolution-markers = [ members = [ "span-panel-api", "span-panel-api-schema-0", + "span-panel-api-schema-1", ] [[package]] @@ -489,6 +490,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] +[[package]] +name = "ebus-mqtt-client" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "paho-mqtt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/05/43c255aac2fe76e51642080315897306751fee1d4414fc6a099a1a5d9af5/ebus_mqtt_client-0.4.0.tar.gz", hash = "sha256:83ac9cfe4672fbbc1622d46ad7fe53d345654ca0dd85109c0894cb9fea8c73b1", size = 27690, upload-time = "2026-08-03T19:58:51.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/f3/e5549b9d340c958bf9ee8927b23ce724b7296a76b395ff0f5cbe55be1f77/ebus_mqtt_client-0.4.0-py3-none-any.whl", hash = "sha256:d64d6ac7f39f42791a59c932ce1cebefadb35accf88b1b3367258fa5fb7f54ff", size = 16625, upload-time = "2026-08-03T19:58:50.606Z" }, +] + +[[package]] +name = "ebus-sdk" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ebus-mqtt-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/82/064f03fbc9da08737d15f9f90ec01fd1c4e9d4d2e8ad17e2699951d20356/ebus_sdk-0.17.0.tar.gz", hash = "sha256:7014d2c73b5eb4befb59b0d9c682b6b56a46698c47201ae49fb61af3b99559bb", size = 141056, upload-time = "2026-08-03T00:25:34.475Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/41/d3f6d5f2af755cce32614654a97eed421efac46fe4d20847643871b3e579/ebus_sdk-0.17.0-py3-none-any.whl", hash = "sha256:5232fa9990298b8785d092f7446e9b4a1a9d4a535a8422666c9b89c57d6248d0", size = 91032, upload-time = "2026-08-03T00:25:33.12Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -1320,6 +1345,7 @@ dev = [ { name = "radon" }, { name = "ruff" }, { name = "span-panel-api-schema-0" }, + { name = "span-panel-api-schema-1" }, { name = "twine" }, { name = "types-pyyaml" }, { name = "vulture" }, @@ -1346,6 +1372,7 @@ dev = [ { name = "radon" }, { name = "ruff", specifier = ">=0.15.5" }, { name = "span-panel-api-schema-0", editable = "packages/schema-0" }, + { name = "span-panel-api-schema-1", editable = "packages/schema-1" }, { name = "twine" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, { name = "vulture", specifier = ">=2.14" }, @@ -1362,6 +1389,21 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "span-panel-api", editable = "." }] +[[package]] +name = "span-panel-api-schema-1" +version = "0.1.0b1" +source = { editable = "packages/schema-1" } +dependencies = [ + { name = "ebus-sdk" }, + { name = "span-panel-api" }, +] + +[package.metadata] +requires-dist = [ + { name = "ebus-sdk", specifier = ">=0.17.0,<1.0" }, + { name = "span-panel-api", editable = "." }, +] + [[package]] name = "stevedore" version = "5.7.0" From 016f99a8f1a8bc4d1401fc9481c40dedae5bfdaa Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:07:36 -0700 Subject: [PATCH 024/115] feat(schema_1): map a v1.0 circuit onto SpanCircuitSnapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of the snapshot mapper. Driven from a real panel_sim parent/child tree captured as tests/fixtures/parent_child_tree.json, so the shapes are the firmware's rather than an invention. Three snapshot fields no longer have a property to read, because v1.0 consolidates four flat mechanisms into two. The migration guide defines the derivations, so they are followed rather than guessed: always-on = not switch/relay-controllable never-backup = not $settable on load-shed/priority sheddable = priority != NEVER and relay-controllable never-backup is the interesting one: v1.0 expresses it as *mutability*, so it is read from the description's $settable attribute rather than a value topic. That also settles the Phase 1 note that schema_1 must accumulate settable attributes — it must, and this is why. Both defaults are chosen so silence cannot invert a fleet. An absent relay-controllable means controllable, and an absent $settable means settable, because each property exists to announce the exception; defaulting the other way would mark every circuit on a panel uncontrollable or never-backup. Two improvements fall out of the new schema. Tabs come from info/spaces, which publishes the occupied spaces literally ("36,38") where the flat schema published one space plus a dipole flag and left the consumer to infer space+2 — so a 3-pole breaker now reports three tabs instead of being truncated to two. And active-power is declared in W, so the flat schema's kW-vs-W deviation does not carry forward. Sign handling is unchanged and deliberately so: the enclosure reference frame means a load reads negative active-power and accumulates exported-energy, both the reverse of what the names suggest, so power is negated and the energy accumulators are swapped. Two tooling fixes this surfaced. The mypy hook gains ebus-sdk, which it needs to resolve the SDK's types in its isolated environment. And pylint gains an init-hook listing every workspace source root: it previously resolved span_panel_api only when a run happened to include a file under src/, so committing an adapter package on its own reported import-error for imports that are fine. 466 tests pass, coverage 94%. --- .pre-commit-config.yaml | 4 + .../src/span_panel_api_schema_1/circuits.py | 177 ++++++++++++++ .../src/span_panel_api_schema_1/const.py | 75 ++++++ pyproject.toml | 5 + tests/fixtures/parent_child_tree.json | 225 ++++++++++++++++++ tests/test_schema_one_circuits.py | 194 +++++++++++++++ 6 files changed, 680 insertions(+) create mode 100644 packages/schema-1/src/span_panel_api_schema_1/circuits.py create mode 100644 packages/schema-1/src/span_panel_api_schema_1/const.py create mode 100644 tests/fixtures/parent_child_tree.json create mode 100644 tests/test_schema_one_circuits.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9054fbd..fb5c050 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -77,6 +77,10 @@ repos: - pytest - types-PyYAML - paho-mqtt + # schema-1 parses the parent/child tree with the eBus SDK, which + # ships py.typed — so the hook needs it installed to resolve those + # types rather than silently reporting import-not-found. + - ebus-sdk>=0.17.0 args: ['--config-file=pyproject.toml'] exclude: '^src/span_panel_api/generated_client/.*|scripts/.*|tests/.*|docs/.*|examples/.*|\..*_cache/.*|dist/.*|venv/.*' diff --git a/packages/schema-1/src/span_panel_api_schema_1/circuits.py b/packages/schema-1/src/span_panel_api_schema_1/circuits.py new file mode 100644 index 0000000..6d4de94 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/circuits.py @@ -0,0 +1,177 @@ +"""Map a v1.0 circuit device onto ``SpanCircuitSnapshot``. + +The snapshot's field names come from the v1 REST API and are preserved so the +integration's entities do not move. Three of them no longer have a property to +read, because v1.0 consolidated four flat mechanisms into two. Their +derivations are defined by the migration guide, not invented here: + +====================== =========================================================== +Flat property v1.0 source +====================== =========================================================== +``always-on`` ``switch/relay-controllable``, inverted +``never-backup`` ``$settable`` on ``load-shed/priority``, inverted +``sheddable`` computed: ``priority != NEVER and relay-controllable`` +====================== =========================================================== + +Sign and direction are unchanged from the flat schema, and both are the reverse +of what the property names suggest. Values are in the enclosure's reference +frame: a normal load reads **negative** ``active-power`` and accumulates +``exported-energy`` (the panel exported it *to* the circuit). The snapshot +reports consumption as positive, so power is negated and the two energy +accumulators are swapped. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from span_panel_api.models import SpanCircuitSnapshot +from span_panel_api_schema_1.const import ( + ATTR_SETTABLE, + NODE_BREAKER, + NODE_INFO, + NODE_LOAD_SHED, + NODE_METER, + NODE_SWITCH, + PRIORITY_NEVER, + PROP_ACTIVE_POWER, + PROP_CURRENT, + PROP_EXPORTED_ENERGY, + PROP_IMPORTED_ENERGY, + PROP_NAME, + PROP_POLES, + PROP_PRIORITY, + PROP_RATING, + PROP_RELAY, + PROP_RELAY_CONTROLLABLE, + PROP_RELAY_REQUESTER, + PROP_SPACES, + UNKNOWN, +) + +if TYPE_CHECKING: + from ebus_sdk.homie import DiscoveredDevice + + +def _text(device: DiscoveredDevice, node: str, prop: str, default: str = "") -> str: + value = device.get_property(node, prop) + return default if value is None else str(value) + + +def _number(device: DiscoveredDevice, node: str, prop: str) -> float | None: + """Read a numeric property, or None when it is absent or unparseable. + + Unparseable is treated as absent rather than as an error: a single + malformed value must not take down a whole snapshot, and the field it + feeds is optional. + """ + raw = device.get_property(node, prop) + if raw is None or raw == "": + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + +def _flag(device: DiscoveredDevice, node: str, prop: str, *, default: bool) -> bool: + """Read a Homie boolean. Absent means `default`, which is not always False. + + `relay-controllable` absent has to mean *controllable*, because the + property exists to mark the exception (an always-on circuit). Defaulting it + to False would silently make every circuit uncontrollable on a panel that + omits it. + """ + raw = device.get_property(node, prop) + if raw is None or raw == "": + return default + return str(raw).strip().lower() == "true" + + +def _tabs(device: DiscoveredDevice) -> list[int]: + """Breaker spaces from ``info/spaces``. + + v1.0 publishes the occupied spaces literally (``"36,38"``), where the flat + schema published one space plus a `dipole` flag and left the consumer to + infer the second as ``space + 2``. Reading the list means a 3-pole breaker + reports three tabs instead of being silently truncated to two. + """ + raw = _text(device, NODE_INFO, PROP_SPACES) + if not raw: + return [] + tabs: list[int] = [] + for part in raw.split(","): + part = part.strip() + if not part: + continue + try: + tabs.append(int(part)) + except ValueError: + continue + return tabs + + +def _priority_is_settable(device: DiscoveredDevice) -> bool: + """Whether ``load-shed/priority`` is user-settable on this circuit. + + This is the successor to the flat ``never-backup`` boolean, and it is read + from the description rather than from a value topic — v1.0 expresses + never-backup as *mutability*, so the signal is the Homie ``$settable`` + attribute on the property definition. + + Absent means settable: locking is the exception a panel announces, so + treating an unannounced circuit as locked would mark every circuit + never-backup on a panel that does not publish the attribute. + """ + definition = device.get_node_properties(NODE_LOAD_SHED).get(PROP_PRIORITY) + if not isinstance(definition, dict): + return True + settable = definition.get(ATTR_SETTABLE) + if settable is None: + return True + if isinstance(settable, bool): + return settable + return str(settable).strip().lower() != "false" + + +def build_circuit( + device: DiscoveredDevice, device_type: str = "circuit", relative_position: str = "" +) -> SpanCircuitSnapshot: + """Build one circuit snapshot from its v1.0 device.""" + raw_power = _number(device, NODE_METER, PROP_ACTIVE_POWER) or 0.0 + # Negate so positive means consumption. The guard keeps -0.0 out of the + # snapshot, where it would compare equal to 0.0 but format as "-0.0". + instant_power_w = 0.0 if raw_power == 0.0 else -raw_power + + relay_controllable = _flag(device, NODE_SWITCH, PROP_RELAY_CONTROLLABLE, default=True) + priority = _text(device, NODE_LOAD_SHED, PROP_PRIORITY, UNKNOWN) + priority_settable = _priority_is_settable(device) + + return SpanCircuitSnapshot( + circuit_id=device.device_id, + name=_text(device, NODE_INFO, PROP_NAME), + relay_state=_text(device, NODE_SWITCH, PROP_RELAY, UNKNOWN), + instant_power_w=instant_power_w, + # The panel *imported* this energy from the circuit, so the circuit + # produced it. Named from the panel's perspective, reported from the + # circuit's. + produced_energy_wh=_number(device, NODE_METER, PROP_IMPORTED_ENERGY) or 0.0, + consumed_energy_wh=_number(device, NODE_METER, PROP_EXPORTED_ENERGY) or 0.0, + tabs=_tabs(device), + priority=priority, + # `always-on` is `not relay-controllable`, and the flat schema derived + # user-controllability from `always-on` — so this is the same answer by + # a shorter route. + is_user_controllable=relay_controllable, + is_sheddable=priority != PRIORITY_NEVER and relay_controllable, + is_never_backup=not priority_settable, + device_type=device_type, + relative_position=relative_position, + is_240v=(_number(device, NODE_BREAKER, PROP_POLES) or 1) >= 2, + current_a=_number(device, NODE_METER, PROP_CURRENT), + breaker_rating_a=_number(device, NODE_BREAKER, PROP_RATING), + always_on=not relay_controllable, + relay_requester=_text(device, NODE_SWITCH, PROP_RELAY_REQUESTER, UNKNOWN), + relay_state_target=device.get_property_target(NODE_SWITCH, PROP_RELAY), + priority_target=device.get_property_target(NODE_LOAD_SHED, PROP_PRIORITY), + ) diff --git a/packages/schema-1/src/span_panel_api_schema_1/const.py b/packages/schema-1/src/span_panel_api_schema_1/const.py new file mode 100644 index 0000000..d28b73a --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/const.py @@ -0,0 +1,75 @@ +"""Wire vocabulary for the parent/child schema (data-model-version 1.x). + +Every name here is a v1.0 device class, capability node, or property id. Nothing +in this module is shared with the flat schema: v1.0 moved each property from a +node on one device to a capability node on its own device, so even names that +look unchanged are addressed differently. +""" + +from __future__ import annotations + +# -- Device classes --------------------------------------------------------- + +TYPE_PANEL = "energy.ebus.device.distribution-enclosure" +TYPE_CIRCUIT = "energy.ebus.device.circuit" +TYPE_BESS = "energy.ebus.device.bess" +TYPE_PV = "energy.ebus.device.pv" +TYPE_EVSE = "energy.ebus.device.evse" +TYPE_MID = "energy.ebus.device.mid" +TYPE_LUGS = "energy.ebus.device.lugs" + +# -- Capability nodes ------------------------------------------------------- + +NODE_BREAKER = "breaker" +NODE_CONNECTION = "connection" +NODE_DOOR = "door" +NODE_GRID = "grid" +NODE_INFO = "info" +NODE_LOAD_SHED = "load-shed" +NODE_METER = "meter" +NODE_PCS = "pcs" +NODE_POWER_FLOWS = "power-flows" +NODE_SHED = "shed" +NODE_SOC = "soc" +NODE_STATUS = "status" +NODE_SWITCH = "switch" + +# -- Properties ------------------------------------------------------------- + +PROP_ACTIVE_POWER = "active-power" +PROP_CURRENT = "current" +PROP_EXPORTED_ENERGY = "exported-energy" +PROP_IMPORTED_ENERGY = "imported-energy" +PROP_NAME = "name" +PROP_POLES = "poles" +PROP_PRIORITY = "priority" +PROP_RATING = "rating" +PROP_RELAY = "relay" +PROP_RELAY_CONTROLLABLE = "relay-controllable" +PROP_RELAY_REQUESTER = "relay-requester" +PROP_SPACES = "spaces" + +# Panel-level +PROP_DATA_MODEL_VERSION = "data-model-version" +PROP_FIRMWARE_VERSION = "firmware-version" +PROP_SERIAL_NUMBER = "serial-number" +PROP_STATE = "state" +PROP_VOLTAGE_A = "voltage-a" +PROP_VOLTAGE_B = "voltage-b" + +# status node +PROP_CLOUD_CONNECTION = "cloud-connection" +PROP_ETHERNET = "ethernet" +PROP_WIFI = "wifi" + +# -- Values ----------------------------------------------------------------- + +PRIORITY_NEVER = "NEVER" +UNKNOWN = "UNKNOWN" +CLOUD_CONNECTED = "CONNECTED" + +# The Homie attribute that carries what the flat schema published as the +# `never-backup` boolean. v1.0 retires the property and expresses it as +# mutability: a circuit commissioned never-backup has its priority locked, so +# the panel publishes `$settable = false` on `load-shed/priority`. +ATTR_SETTABLE = "settable" diff --git a/pyproject.toml b/pyproject.toml index d1909b6..d0e3bd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -192,6 +192,11 @@ exclude_dirs = ["tests", "scripts"] [tool.pylint.main] load-plugins = ["pylint.extensions.no_self_use"] extension-pkg-allow-list = [] +# Every source root in the workspace, so cross-package imports resolve no matter +# which files a run happens to cover. Without this, pylint only finds +# `span_panel_api` when a run also includes a file under `src/` — so committing +# an adapter package on its own reports import-error for imports that are fine. +init-hook = "import sys; sys.path[:0] = ['src', 'packages/schema-0/src', 'packages/schema-1/src']" ignore-paths = [ "^tests/.*", "^scripts/.*", diff --git a/tests/fixtures/parent_child_tree.json b/tests/fixtures/parent_child_tree.json new file mode 100644 index 0000000..f6c2591 --- /dev/null +++ b/tests/fixtures/parent_child_tree.json @@ -0,0 +1,225 @@ +{ + "0ab966b95f92a6a51ec548485aa85f54": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Kitchen Lights", + "info/spaces": "1", + "load-shed/priority": "UNKNOWN", + "meter/active-power": "-121.0", + "meter/current": "1.0083333333333333", + "meter/exported-energy": "2.0166666666666666", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "1", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "573066aaddd7b75114c4563ce3af18c4": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "30", + "connection/feeds-device-id": "pv", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.pv", + "info/name": "Solar Inverter", + "info/spaces": "36,38", + "load-shed/priority": "NEVER", + "meter/active-power": "8500.0", + "meter/current": "35.416666666666664", + "meter/exported-energy": "0.0", + "meter/imported-energy": "141.66666666666666", + "pcs/managed": "false", + "pcs/priority": "5", + "switch/relay": "CLOSED", + "switch/relay-controllable": "false", + "switch/relay-requester": "NONE" + }, + "62d0e03897b337b57101aae82f1e9ba2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "connection/feeds-device-id": "evse", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", + "info/name": "SPAN Drive - Garage", + "info/spaces": "32,34", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-2410.0", + "meter/current": "10.041666666666666", + "meter/exported-energy": "40.166666666666664", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "3", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "bess": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/model": "Example BESS", + "info/nameplate-capacity": "13.5", + "info/vendor-name": "Span", + "meter/active-power": "-3500.0", + "soc/soc": "50.410493827160494", + "soc/soe": "6.805416666666667", + "status/communication-state": "OK" + }, + "bess-mid": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}", + "$state": "ready", + "grid/grid-forming-entity": "GRID", + "grid/grid-state": "UP", + "grid/islanding-state": "ON_GRID", + "info/vendor-name": "Span" + }, + "d3724e0d660ba506aa79c1cafe5d1181": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Garage Outlet", + "info/spaces": "2", + "load-shed/priority": "UNKNOWN", + "meter/active-power": "-122.0", + "meter/current": "1.0166666666666666", + "meter/exported-energy": "2.033333333333333", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "2", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "evse": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "example/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-example-40t-001", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "CHARGING", + "switch/lock-state": "LOCKED" + }, + "evse-2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "example/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-example-40t-001-2", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "AVAILABLE", + "switch/lock-state": "UNLOCKED" + }, + "example-40t-001": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", + "$state": "ready", + "breaker/rating": "200", + "door/state": "CLOSED", + "info/data-model-version": "1.0", + "info/firmware-version": "example/v0.1.0", + "info/hardware-version": "rev2", + "info/model": "MAIN_40", + "info/serial-number": "example-40t-001", + "info/vendor-name": "Span", + "meter/voltage-a": "120.0", + "meter/voltage-b": "120.0", + "pcs/active": "false", + "pcs/binding-constraint": "NONE", + "pcs/enabled": "false", + "pcs/feed-import-limit": "0.0", + "pcs/feed-import-limit-active": "false", + "pcs/feed-import-limit-enablement": "UNCONFIGURED", + "pcs/import-limit": "0.0", + "pcs/off-grid-import-limit": "0.0", + "pcs/off-grid-import-limit-active": "false", + "pcs/off-grid-import-limit-enablement": "UNCONFIGURED", + "pcs/operator-import-limit": "0.0", + "pcs/operator-import-limit-active": "false", + "pcs/operator-import-limit-enablement": "UNCONFIGURED", + "pcs/requested-import-limit": "0.0", + "pcs/requested-import-limit-active": "false", + "pcs/requested-import-limit-enablement": "UNCONFIGURED", + "power-flows/battery": "-3500.0", + "power-flows/grid": "-2347.0", + "power-flows/pv": "8500.0", + "power-flows/site": "2653.0", + "shed-forecast/confidence": "HIGH", + "shed-forecast/full-charge-time-to-priority-shed": "3038", + "shed-forecast/full-charge-total-time-remaining": "4320", + "shed-forecast/time-to-priority-shed": "3037", + "shed-forecast/total-time-remaining": "4320", + "shed/asserted-islanding-state": "NONE", + "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}", + "status/cloud-connection": "CONNECTED", + "status/ethernet": "true", + "status/postal-code": "94103", + "status/relay": "CLOSED", + "status/time-zone": "America/Los_Angeles", + "status/wifi": "true" + }, + "fe8b85c15bc9610c1b8b4ebc6f82488d": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "connection/feeds-device-id": "evse-2", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", + "info/name": "SPAN Drive - Driveway", + "info/spaces": "35,37", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "0.0", + "meter/current": "0.0", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "4", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "lugs-downstream": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/direction": "DOWNSTREAM", + "meter/active-power": "-5847.0", + "meter/current-a": "46.46666666666666", + "meter/current-b": "46.474999999999994", + "meter/exported-energy": "141.66666666666666", + "meter/imported-energy": "44.21666666666666" + }, + "lugs-upstream": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "connection/fed-by-device-id": "bess", + "connection/fed-by-device-status": "OK", + "connection/fed-by-device-type": "energy.ebus.device.bess", + "info/direction": "UPSTREAM", + "meter/active-power": "-5847.0", + "meter/current-a": "46.46666666666666", + "meter/current-b": "46.474999999999994", + "meter/exported-energy": "141.66666666666666", + "meter/imported-energy": "44.21666666666666" + }, + "pv": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/model": "IQ8PLUS-72-2-US", + "info/nominal-power": "10000.0", + "info/vendor-name": "Enphase" + } +} diff --git a/tests/test_schema_one_circuits.py b/tests/test_schema_one_circuits.py new file mode 100644 index 0000000..61034a2 --- /dev/null +++ b/tests/test_schema_one_circuits.py @@ -0,0 +1,194 @@ +"""Mapping a v1.0 circuit device onto SpanCircuitSnapshot. + +Driven from `fixtures/parent_child_tree.json`, captured off a real +`panel_sim` parent/child tree rather than hand-written, so the shapes are the +firmware's rather than my idea of them. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from ebus_sdk.homie import DiscoveredDevice + +from span_panel_api_schema_1.circuits import build_circuit + +_TREE = json.loads((Path(__file__).parent / "fixtures" / "parent_child_tree.json").read_text(encoding="utf-8")) + +# From the fixture: a 1-pole load, and a 2-pole backfeeding PV breaker. +KITCHEN_LIGHTS = "0ab966b95f92a6a51ec548485aa85f54" +SOLAR_INVERTER = "573066aaddd7b75114c4563ce3af18c4" + + +def _device(device_id: str) -> DiscoveredDevice: + """Rebuild a DiscoveredDevice from the captured retained topics.""" + topics = _TREE[device_id] + device = DiscoveredDevice(device_id, "ebus") + device.update_description(topics["$description"]) + device.update_state(topics["$state"]) + for topic, value in topics.items(): + if topic.startswith("$"): + continue + node, _, prop = topic.partition("/") + if prop: + device.update_property(node, prop, value) + return device + + +@pytest.fixture(name="kitchen") +def _kitchen() -> DiscoveredDevice: + return _device(KITCHEN_LIGHTS) + + +@pytest.fixture(name="solar") +def _solar() -> DiscoveredDevice: + return _device(SOLAR_INVERTER) + + +def test_identity_and_name(kitchen: DiscoveredDevice) -> None: + circuit = build_circuit(kitchen) + + assert circuit.circuit_id == KITCHEN_LIGHTS + assert circuit.name == "Kitchen Lights" + assert circuit.relay_state == "CLOSED" + + +def test_a_load_reports_positive_consumption(kitchen: DiscoveredDevice) -> None: + """The enclosure frame is the reverse of what the names suggest. + + A load reads negative `active-power` because power flows *out* of the + panel into it. The snapshot reports consumption as positive, so the sign + flips here. Getting this backwards is the classic silent defect: every + number still looks plausible. + """ + assert kitchen.get_property("meter", "active-power") == "-121.0" + + assert build_circuit(kitchen).instant_power_w == 121.0 + + +def test_a_backfeeding_circuit_reports_negative_consumption(solar: DiscoveredDevice) -> None: + assert solar.get_property("meter", "active-power") == "8500.0" + + assert build_circuit(solar).instant_power_w == -8500.0 + + +def test_energy_accumulators_are_swapped_to_the_circuit_perspective(solar: DiscoveredDevice) -> None: + """`imported-energy` is named from the panel's side: energy the panel took + *from* the circuit, which the circuit produced.""" + circuit = build_circuit(solar) + + assert solar.get_property("meter", "imported-energy") == "141.66666666666666" + assert circuit.produced_energy_wh == pytest.approx(141.666666, rel=1e-6) + assert circuit.consumed_energy_wh == 0.0 + + +def test_tabs_come_from_the_published_list_not_a_derivation(solar: DiscoveredDevice) -> None: + """v1.0 publishes occupied spaces literally. The flat schema published one + space plus a `dipole` flag and left the consumer to infer `space + 2`.""" + assert solar.get_property("info", "spaces") == "36,38" + + assert build_circuit(solar).tabs == [36, 38] + + +def test_single_pole_circuit(kitchen: DiscoveredDevice) -> None: + circuit = build_circuit(kitchen) + + assert circuit.tabs == [1] + assert circuit.is_240v is False + + +def test_two_pole_circuit_is_240v(solar: DiscoveredDevice) -> None: + assert build_circuit(solar).is_240v is True + + +def test_breaker_rating_and_current(kitchen: DiscoveredDevice) -> None: + circuit = build_circuit(kitchen) + + assert circuit.breaker_rating_a == 15.0 + assert circuit.current_a == pytest.approx(1.00833, rel=1e-4) + + +# --------------------------------------------------------------------------- +# The three flat booleans v1.0 retired +# --------------------------------------------------------------------------- + + +def test_always_on_is_the_inverse_of_relay_controllable(kitchen: DiscoveredDevice, solar: DiscoveredDevice) -> None: + """`always-on` is retired; the migration guide defines + `relay-controllable = !always-on`.""" + assert kitchen.get_property("switch", "relay-controllable") == "true" + assert solar.get_property("switch", "relay-controllable") == "false" + + assert build_circuit(kitchen).always_on is False + assert build_circuit(kitchen).is_user_controllable is True + assert build_circuit(solar).always_on is True + assert build_circuit(solar).is_user_controllable is False + + +def test_relay_controllable_defaults_to_controllable_when_absent(kitchen: DiscoveredDevice) -> None: + """The property marks the exception. Defaulting it False would silently + make every circuit uncontrollable on a panel that omits it.""" + kitchen.update_property("switch", "relay-controllable", "") + + assert build_circuit(kitchen).is_user_controllable is True + + +def test_sheddable_is_computed_not_read(kitchen: DiscoveredDevice, solar: DiscoveredDevice) -> None: + """Retired with no replacement property: the guide defines it as + `priority != NEVER and relay-controllable`.""" + # Kitchen: priority UNKNOWN (not NEVER) and controllable -> sheddable + assert build_circuit(kitchen).is_sheddable is True + # Solar: priority NEVER and not controllable -> not sheddable + assert solar.get_property("load-shed", "priority") == "NEVER" + assert build_circuit(solar).is_sheddable is False + + +def test_never_backup_reads_the_settable_attribute(kitchen: DiscoveredDevice) -> None: + """v1.0 expresses never-backup as mutability, so the signal is the Homie + `$settable` attribute on the priority definition, not a value topic.""" + definition = kitchen.get_node_properties("load-shed")["priority"] + assert definition["settable"] is True + + assert build_circuit(kitchen).is_never_backup is False + + +def test_a_locked_priority_means_never_backup(kitchen: DiscoveredDevice) -> None: + description = json.loads(_TREE[KITCHEN_LIGHTS]["$description"]) + description["nodes"]["load-shed"]["properties"]["priority"]["settable"] = False + kitchen.update_description(json.dumps(description)) + + assert build_circuit(kitchen).is_never_backup is True + + +def test_an_unannounced_settable_means_settable(kitchen: DiscoveredDevice) -> None: + """Locking is what a panel announces. Treating silence as locked would mark + every circuit never-backup on firmware that omits the attribute.""" + description = json.loads(_TREE[KITCHEN_LIGHTS]["$description"]) + del description["nodes"]["load-shed"]["properties"]["priority"]["settable"] + kitchen.update_description(json.dumps(description)) + + assert build_circuit(kitchen).is_never_backup is False + + +# --------------------------------------------------------------------------- +# Robustness +# --------------------------------------------------------------------------- + + +def test_an_unreadable_number_is_treated_as_absent_not_fatal(kitchen: DiscoveredDevice) -> None: + """One malformed value must not take down a whole snapshot.""" + kitchen.update_property("meter", "current", "not-a-number") + + assert build_circuit(kitchen).current_a is None + + +def test_zero_power_never_becomes_negative_zero(kitchen: DiscoveredDevice) -> None: + """-0.0 compares equal to 0.0 but formats as '-0.0' in the UI.""" + kitchen.update_property("meter", "active-power", "0.0") + + from math import copysign + + assert copysign(1.0, build_circuit(kitchen).instant_power_w) == 1.0 From 2e06b142b1048394c416e024c3f43ce63dc6b658 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:23:10 -0700 Subject: [PATCH 025/115] feat(schema_1): map the panel, lugs and MID onto SpanPanelSnapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the mapper's read side. v1.0 spreads what the flat schema kept on one device across the panel and its children: the grid connection is the upstream lugs device, feedthrough is the downstream lugs, and grid state moved to the MID, which is where islanding is actually decided. Direction is per-device and the two rules are opposites, so they are kept apart rather than sharing a helper. A circuit needs flipping because the panel exports to a load; the lugs do not, because the panel imports from the grid and the enclosure frame already reports import-positive there. Reading the lugs with the circuit rule would invert every grid figure while leaving it entirely plausible. Retired panel fields are left None rather than substituted. dominant-power- source split into grid-forming-entity plus asserted-islanding-state and grid-islandable was removed outright; picking a stand-in for either would be a silent product decision. panel_size has no v1.0 source at all, which this work established rather than assumed: the flat schema carried it in the Homie schema's `space` format ("1:32:1"), its successor info/spaces is a plain string with no format, the panel's info node publishes no size, and the migration guide never maps one. The highest occupied space is implemented as an explicit lower bound — a 40-space panel whose highest occupied slot is 36 reports 36 — so unmapped-tab synthesis is not reproducible from the wire. Parsing info/model ("MAIN_40") was rejected as undocumented vendor parsing that breaks silently. Recorded in the entity and config deltas write-up as needing a product decision or an upstream question. Lugs are located by info/direction rather than device id: the ids in the reference tree are the simulator's naming, the direction property is what the schema defines. 481 tests pass, coverage 94%. --- .../src/span_panel_api_schema_1/panel.py | 198 ++++++++++++++++++ tests/test_schema_one_panel.py | 172 +++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 packages/schema-1/src/span_panel_api_schema_1/panel.py create mode 100644 tests/test_schema_one_panel.py diff --git a/packages/schema-1/src/span_panel_api_schema_1/panel.py b/packages/schema-1/src/span_panel_api_schema_1/panel.py new file mode 100644 index 0000000..9c9ff7b --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/panel.py @@ -0,0 +1,198 @@ +"""Map the v1.0 device tree onto the panel-level fields of ``SpanPanelSnapshot``. + +Where the flat schema kept everything on one device's nodes, v1.0 spreads the +same information across the panel and its children: the grid connection is the +upstream lugs device, feedthrough is the downstream lugs device, and grid state +lives on the MID. + +**Direction is per-device, and the two rules are opposites.** Everything is +stated in the enclosure's reference frame — power flowing *into* the panel is +positive — so: + +* **Circuits** need flipping (see ``circuits.py``): the panel exports to a load, + so a load reads negative and accumulates ``exported-energy``. +* **Lugs** do not: the panel imports from the grid, so drawing from the grid + reads positive and accumulates ``imported-energy``, which is already the + house's consumption. + +Reading the lugs with the circuit rule would inverting every grid figure while +leaving it plausible, which is why the two are separated here rather than +sharing a helper. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from span_panel_api_schema_1.const import ( + CLOUD_CONNECTED, + NODE_BREAKER, + NODE_DOOR, + NODE_GRID, + NODE_INFO, + NODE_METER, + NODE_POWER_FLOWS, + NODE_STATUS, + PROP_ACTIVE_POWER, + PROP_CLOUD_CONNECTION, + PROP_ETHERNET, + PROP_EXPORTED_ENERGY, + PROP_FIRMWARE_VERSION, + PROP_IMPORTED_ENERGY, + PROP_RATING, + PROP_RELAY, + PROP_SERIAL_NUMBER, + PROP_STATE, + PROP_VOLTAGE_A, + PROP_VOLTAGE_B, + PROP_WIFI, + UNKNOWN, +) + +if TYPE_CHECKING: + from ebus_sdk.homie import DiscoveredDevice + +_LOGGER = logging.getLogger(__name__) + +# Lugs `meter` exposes per-phase current under these ids; circuits expose a +# single `current`. Same capability type, different property set — v1.0 defines +# capabilities as a semantic namespace rather than a fixed contract. +PROP_CURRENT_A = "current-a" +PROP_CURRENT_B = "current-b" + +PROP_GRID_STATE = "grid-state" +PROP_DIRECTION = "direction" +DIRECTION_UPSTREAM = "UPSTREAM" + + +def text(device: DiscoveredDevice | None, node: str, prop: str, default: str = "") -> str: + if device is None: + return default + value = device.get_property(node, prop) + return default if value is None else str(value) + + +def number(device: DiscoveredDevice | None, node: str, prop: str) -> float | None: + if device is None: + return None + raw = device.get_property(node, prop) + if raw is None or raw == "": + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + +def flag(device: DiscoveredDevice | None, node: str, prop: str) -> bool: + return text(device, node, prop).strip().lower() == "true" + + +def panel_size_from_tabs(occupied: list[int]) -> int: + """Best-effort panel size: the highest occupied breaker space. + + **This is a lower bound, not the panel's size**, and v1.0 gives us nothing + better. The flat schema carried the true size in the Homie schema's `space` + format (`"1:32:1"`, max = 32). Its v1.0 successor, `info/spaces`, is a plain + string with no format, the panel device publishes no size property, and the + migration guide maps `space` to `spaces` without mentioning panel size at + all. + + So a 40-space panel whose highest occupied slot is 36 reports 36. Anything + that enumerates *unoccupied* slots — the flat parser's unmapped-tab + synthesis — is therefore not reproducible from the wire under v1.0. + + Treated as a product question rather than papered over: see the v1.0 + user-visible entity and config deltas write-up. Deriving it from + `info/model` (`"MAIN_40"`) would work on today's firmware and is exactly the + kind of undocumented vendor parsing that breaks silently later. + """ + if not occupied: + return 0 + return max(occupied) + + +def find_lugs(devices: list[DiscoveredDevice], upstream: bool) -> DiscoveredDevice | None: + """Locate a lugs device by its declared direction. + + Matched on `info/direction` rather than device id: the ids in the reference + tree (`lugs-upstream`) are the simulator's naming, while the direction + property is what the schema defines. + """ + want = DIRECTION_UPSTREAM + for device in devices: + direction = text(device, NODE_INFO, PROP_DIRECTION).strip().upper() + if not direction: + continue + if (direction == want) is upstream: + return device + return None + + +class PanelFields: + """Panel-level values gathered from the tree, ready for the snapshot. + + A class rather than a long argument list because the caller assembles a + frozen dataclass with ~30 fields, and passing them positionally is how a + voltage ends up in a current. + """ + + def __init__( + self, + panel: DiscoveredDevice, + upstream_lugs: DiscoveredDevice | None, + downstream_lugs: DiscoveredDevice | None, + mid: DiscoveredDevice | None, + ) -> None: + self.serial_number = text(panel, NODE_INFO, PROP_SERIAL_NUMBER, panel.device_id) + self.firmware_version = text(panel, NODE_INFO, PROP_FIRMWARE_VERSION) + self.main_relay_state = text(panel, NODE_STATUS, PROP_RELAY, UNKNOWN) + self.door_state = text(panel, NODE_DOOR, PROP_STATE, UNKNOWN) + + self.eth0_link = flag(panel, NODE_STATUS, PROP_ETHERNET) + self.wlan_link = flag(panel, NODE_STATUS, PROP_WIFI) + self.vendor_cloud = text(panel, NODE_STATUS, PROP_CLOUD_CONNECTION) or None + # v1 exposed a WWAN radio link; v2 has no such property, so the flat + # adapter reported cloud reachability instead. Kept identical here so + # the entity does not change meaning between adapters. + self.wwan_link = self.vendor_cloud == CLOUD_CONNECTED + + self.l1_voltage = number(panel, NODE_METER, PROP_VOLTAGE_A) + self.l2_voltage = number(panel, NODE_METER, PROP_VOLTAGE_B) + rating = number(panel, NODE_BREAKER, PROP_RATING) + self.main_breaker_rating_a = None if rating is None else int(rating) + + self.power_flow_pv = number(panel, NODE_POWER_FLOWS, "pv") + self.power_flow_battery = number(panel, NODE_POWER_FLOWS, "battery") + self.power_flow_grid = number(panel, NODE_POWER_FLOWS, "grid") + self.power_flow_site = number(panel, NODE_POWER_FLOWS, "site") + + # Upstream lugs are the grid connection. No sign flip: the enclosure + # frame already reports import-positive, which is what consumption + # means here. + self.instant_grid_power_w = number(upstream_lugs, NODE_METER, PROP_ACTIVE_POWER) or 0.0 + self.main_meter_energy_consumed_wh = number(upstream_lugs, NODE_METER, PROP_IMPORTED_ENERGY) or 0.0 + self.main_meter_energy_produced_wh = number(upstream_lugs, NODE_METER, PROP_EXPORTED_ENERGY) or 0.0 + self.upstream_l1_current_a = number(upstream_lugs, NODE_METER, PROP_CURRENT_A) + self.upstream_l2_current_a = number(upstream_lugs, NODE_METER, PROP_CURRENT_B) + + self.feedthrough_power_w = number(downstream_lugs, NODE_METER, PROP_ACTIVE_POWER) or 0.0 + self.feedthrough_energy_consumed_wh = number(downstream_lugs, NODE_METER, PROP_IMPORTED_ENERGY) or 0.0 + self.feedthrough_energy_produced_wh = number(downstream_lugs, NODE_METER, PROP_EXPORTED_ENERGY) or 0.0 + self.downstream_l1_current_a = number(downstream_lugs, NODE_METER, PROP_CURRENT_A) + self.downstream_l2_current_a = number(downstream_lugs, NODE_METER, PROP_CURRENT_B) + + # Grid state moved to the MID device, which is where islanding is + # actually decided. Absent when the panel has no MID. + self.grid_state = text(mid, NODE_GRID, PROP_GRID_STATE) or None + + # Retired in v1.0 with no drop-in successor, and deliberately left + # None rather than substituted: `dominant-power-source` split into + # grid-forming-entity plus asserted-islanding-state, and + # `grid-islandable` was removed outright. Both are product decisions, + # tracked in the entity and config deltas write-up. + self.dominant_power_source: str | None = None + self.grid_islandable: bool | None = None + # Not published by v1.0 firmware. + self.wifi_ssid: str | None = None diff --git a/tests/test_schema_one_panel.py b/tests/test_schema_one_panel.py new file mode 100644 index 0000000..704ba6d --- /dev/null +++ b/tests/test_schema_one_panel.py @@ -0,0 +1,172 @@ +"""Panel-level mapping from the v1.0 tree. + +Driven from `fixtures/parent_child_tree.json`, captured off a real `panel_sim` +parent/child tree. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from ebus_sdk.homie import DiscoveredDevice + +from span_panel_api_schema_1.panel import PanelFields, find_lugs, panel_size_from_tabs + +_TREE = json.loads((Path(__file__).parent / "fixtures" / "parent_child_tree.json").read_text(encoding="utf-8")) + +PANEL = "example-40t-001" + + +def _device(device_id: str) -> DiscoveredDevice: + topics = _TREE[device_id] + device = DiscoveredDevice(device_id, "ebus") + device.update_description(topics["$description"]) + device.update_state(topics["$state"]) + for topic, value in topics.items(): + if topic.startswith("$"): + continue + node, _, prop = topic.partition("/") + if prop: + device.update_property(node, prop, value) + return device + + +@pytest.fixture(name="fields") +def _fields() -> PanelFields: + return PanelFields( + panel=_device(PANEL), + upstream_lugs=_device("lugs-upstream"), + downstream_lugs=_device("lugs-downstream"), + mid=_device("bess-mid"), + ) + + +def test_identity(fields: PanelFields) -> None: + assert fields.serial_number == "example-40t-001" + assert fields.firmware_version == "example/v0.1.0" + + +def test_hardware_status(fields: PanelFields) -> None: + assert fields.main_relay_state == "CLOSED" + assert fields.door_state == "CLOSED" + assert fields.eth0_link is True + assert fields.wlan_link is True + assert fields.main_breaker_rating_a == 200 + + +def test_wwan_link_reports_cloud_reachability(fields: PanelFields) -> None: + """v1 exposed a WWAN radio link and v2 has no such property, so the flat + adapter reported cloud reachability. Kept identical so the entity does not + change meaning between adapters.""" + assert fields.vendor_cloud == "CONNECTED" + assert fields.wwan_link is True + + +def test_voltages_come_from_the_panel_meter(fields: PanelFields) -> None: + assert fields.l1_voltage == 120.0 + assert fields.l2_voltage == 120.0 + + +def test_power_flows(fields: PanelFields) -> None: + assert fields.power_flow_pv == 8500.0 + assert fields.power_flow_battery == -3500.0 + assert fields.power_flow_grid == -2347.0 + assert fields.power_flow_site == 2653.0 + + +# --------------------------------------------------------------------------- +# Lugs — the direction rule that is the opposite of a circuit's +# --------------------------------------------------------------------------- + + +def test_grid_power_is_not_negated(fields: PanelFields) -> None: + """The enclosure frame already reports import-positive at the lugs, which + is what consumption means there. Applying the circuit rule would invert + every grid figure while leaving it entirely plausible.""" + raw = _device("lugs-upstream").get_property("meter", "active-power") + assert raw == "-5847.0" + + assert fields.instant_grid_power_w == -5847.0 + + +def test_main_meter_energy_maps_imported_to_consumed(fields: PanelFields) -> None: + """Opposite of a circuit: the panel imports from the grid, so imported + energy is what the house consumed.""" + upstream = _device("lugs-upstream") + assert upstream.get_property("meter", "imported-energy") == "44.21666666666666" + + assert fields.main_meter_energy_consumed_wh == pytest.approx(44.2166, rel=1e-4) + assert fields.main_meter_energy_produced_wh == pytest.approx(141.6666, rel=1e-4) + + +def test_per_phase_currents(fields: PanelFields) -> None: + """Lugs expose `current-a`/`current-b`; circuits expose a single `current`. + Same capability type, different property set.""" + assert fields.upstream_l1_current_a == pytest.approx(46.4666, rel=1e-4) + assert fields.upstream_l2_current_a == pytest.approx(46.4749, rel=1e-4) + assert fields.downstream_l1_current_a == pytest.approx(46.4666, rel=1e-4) + + +def test_feedthrough_comes_from_the_downstream_lugs(fields: PanelFields) -> None: + assert fields.feedthrough_power_w == -5847.0 + assert fields.feedthrough_energy_consumed_wh == pytest.approx(44.2166, rel=1e-4) + + +def test_lugs_are_found_by_declared_direction_not_device_id() -> None: + """Device ids in the reference tree are the simulator's naming; direction + is what the schema defines.""" + devices = [_device("lugs-downstream"), _device("lugs-upstream")] + + assert find_lugs(devices, upstream=True).device_id == "lugs-upstream" + assert find_lugs(devices, upstream=False).device_id == "lugs-downstream" + + +def test_missing_lugs_yield_zeros_not_errors() -> None: + """A panel without lugs devices must still produce a snapshot.""" + fields = PanelFields(panel=_device(PANEL), upstream_lugs=None, downstream_lugs=None, mid=None) + + assert fields.instant_grid_power_w == 0.0 + assert fields.upstream_l1_current_a is None + assert fields.grid_state is None + + +# --------------------------------------------------------------------------- +# Moved and retired +# --------------------------------------------------------------------------- + + +def test_grid_state_comes_from_the_mid(fields: PanelFields) -> None: + """It moved off the panel to the device where islanding is decided.""" + assert fields.grid_state == "UP" + + +def test_retired_fields_are_none_rather_than_substituted(fields: PanelFields) -> None: + """`dominant-power-source` split into grid-forming-entity plus + asserted-islanding-state, and `grid-islandable` was removed outright. + Substituting either would be a silent product decision.""" + assert fields.dominant_power_source is None + assert fields.grid_islandable is None + + +# --------------------------------------------------------------------------- +# Panel size — the gap +# --------------------------------------------------------------------------- + + +def test_panel_size_is_a_lower_bound_from_occupied_spaces() -> None: + """v1.0 publishes no panel size anywhere: `info/spaces` is a plain string + with no format, the panel device has no size property, and the migration + guide does not map one. The highest occupied space is the best available + answer and it undercounts.""" + assert panel_size_from_tabs([1, 3, 36, 38]) == 38 + assert panel_size_from_tabs([]) == 0 + + +def test_panel_size_undercounts_a_sparsely_populated_panel() -> None: + """The failure this documents: a 40-space panel whose highest occupied slot + is 36 reports 36, so anything enumerating unoccupied slots is not + reproducible from the wire.""" + assert panel_size_from_tabs([1, 2, 36]) == 36 From aac0def469fad3e1c90c4b532ab16a4620815747 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:35:06 -0700 Subject: [PATCH 026/115] feat(schema_1): derive panel size from the model, restoring unmapped positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects the previous commit, which took the highest occupied space as a best-effort panel size and rejected reading info/model as undocumented vendor parsing. Both were wrong. info/model is a closed enum — MAIN_16, MLO_24, MAIN_32, MAIN_40, MLO_48 — listed in the topic reference and the migration guide, and advertised by the panel itself as a Homie $format on the property, confirmed against a real tree. A lookup over a defined value set is not an inference from a vendor string. The lower bound was worse than imprecise, it was destructive. Unoccupied positions are exactly total minus occupied, so a bound taken from the highest occupied space does not undercount a display value — it deletes every position above it. A 40-space panel with nothing above slot 36 would have silently lost four unmapped-circuit sensors, which the integration exposes as entities and gates behind its own enable_unmapped_circuit_sensors option. So unmapped-tab synthesis is reproducible under v1.0 after all, and is implemented here. The unmapped_tab_ id format is kept identical to the flat adapter's because the integration builds entity ids from it (sensor.span_panel_unmapped_tab_32_power); renaming would strand every existing unmapped entity. One asymmetry is worth the extra code: the panel states which models are valid, but neither the published schema nor the eBus SDK states how many spaces each model has — that half is ours. A firmware adding a model would therefore advertise a value we cannot size, so panel_model_drift compares the advertised $format against the table at connect time and warns. Same reasoning as the flat adapter's schema-drift detection: the failure is otherwise a silent absence. An unknown model yields size 0 and no unmapped positions rather than a guess, because a wrong total fabricates positions that do not exist or hides real ones. 488 tests pass, coverage 94%. --- .../src/span_panel_api_schema_1/const.py | 33 ++++++ .../src/span_panel_api_schema_1/panel.py | 111 +++++++++++++++--- tests/test_schema_one_panel.py | 96 ++++++++++++--- 3 files changed, 209 insertions(+), 31 deletions(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/const.py b/packages/schema-1/src/span_panel_api_schema_1/const.py index d28b73a..2c25da7 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/const.py +++ b/packages/schema-1/src/span_panel_api_schema_1/const.py @@ -68,6 +68,39 @@ UNKNOWN = "UNKNOWN" CLOUD_CONNECTED = "CONNECTED" +PROP_MODEL = "model" + +# Breaker spaces per panel model. +# +# This is the only source of the panel's total size in v1.0. The flat schema +# carried it in the Homie schema's `space` format (`"1:32:1"`, max = 32); its +# successor `info/spaces` is a plain string with no format, and the panel device +# publishes no size property. What v1.0 does publish is `info/model`, a **closed +# enum** — the topic reference, the migration guide, and the panel's own Homie +# `$format` all list exactly these five values — so this is a lookup over a +# defined value set, not an inference from a vendor string. +# +# The *sizes* are ours: neither the SDK nor the published schema states how many +# spaces a model has, only which model names are valid. `panel_model_drift` +# exists because of that split — the panel can tell us a model we have no size +# for, and we would rather say so than guess. +# +# Total size matters beyond a display field: unoccupied positions are only +# knowable as `total - occupied`, and synthesising them is what gives the +# integration its unmapped-circuit sensors. +PANEL_SIZE_BY_MODEL: dict[str, int] = { + "MAIN_16": 16, + "MLO_24": 24, + "MAIN_32": 32, + "MAIN_40": 40, + "MLO_48": 48, +} + +# Prefix for synthesised unoccupied-position entries. Must match the flat +# adapter's, because the integration keys entities off it and a rename would +# strand every existing unmapped-tab entity. +UNMAPPED_TAB_PREFIX = "unmapped_tab_" + # The Homie attribute that carries what the flat schema published as the # `never-backup` boolean. v1.0 retires the property and expresses it as # mutability: a circuit commissioned never-backup has its priority locked, so diff --git a/packages/schema-1/src/span_panel_api_schema_1/panel.py b/packages/schema-1/src/span_panel_api_schema_1/panel.py index 9c9ff7b..5206ca2 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/panel.py +++ b/packages/schema-1/src/span_panel_api_schema_1/panel.py @@ -25,6 +25,7 @@ import logging from typing import TYPE_CHECKING +from span_panel_api.models import SpanCircuitSnapshot from span_panel_api_schema_1.const import ( CLOUD_CONNECTED, NODE_BREAKER, @@ -34,12 +35,14 @@ NODE_METER, NODE_POWER_FLOWS, NODE_STATUS, + PANEL_SIZE_BY_MODEL, PROP_ACTIVE_POWER, PROP_CLOUD_CONNECTION, PROP_ETHERNET, PROP_EXPORTED_ENERGY, PROP_FIRMWARE_VERSION, PROP_IMPORTED_ENERGY, + PROP_MODEL, PROP_RATING, PROP_RELAY, PROP_SERIAL_NUMBER, @@ -48,6 +51,7 @@ PROP_VOLTAGE_B, PROP_WIFI, UNKNOWN, + UNMAPPED_TAB_PREFIX, ) if TYPE_CHECKING: @@ -89,28 +93,101 @@ def flag(device: DiscoveredDevice | None, node: str, prop: str) -> bool: return text(device, node, prop).strip().lower() == "true" -def panel_size_from_tabs(occupied: list[int]) -> int: - """Best-effort panel size: the highest occupied breaker space. +def panel_size_from_model(model: str) -> int: + """Total breaker spaces for a panel model, or 0 when the model is unknown. - **This is a lower bound, not the panel's size**, and v1.0 gives us nothing - better. The flat schema carried the true size in the Homie schema's `space` - format (`"1:32:1"`, max = 32). Its v1.0 successor, `info/spaces`, is a plain - string with no format, the panel device publishes no size property, and the - migration guide maps `space` to `spaces` without mentioning panel size at - all. + `info/model` is the only place v1.0 states the panel's size. The flat schema + carried it in the Homie schema's `space` format (`"1:32:1"`, max = 32); the + successor `info/spaces` is a plain string with no format, and the panel + device publishes no size property. - So a 40-space panel whose highest occupied slot is 36 reports 36. Anything - that enumerates *unoccupied* slots — the flat parser's unmapped-tab - synthesis — is therefore not reproducible from the wire under v1.0. + The highest *occupied* space is not a substitute: it is a lower bound, so a + 40-space panel whose highest occupied slot is 36 would report 36 and every + position above it would silently cease to exist. Since unoccupied positions + are exactly `total - occupied`, that would delete the integration's + unmapped-circuit sensors rather than merely miscount a display value. - Treated as a product question rather than papered over: see the v1.0 - user-visible entity and config deltas write-up. Deriving it from - `info/model` (`"MAIN_40"`) would work on today's firmware and is exactly the - kind of undocumented vendor parsing that breaks silently later. + Unknown models return 0 and log, because inventing a size is worse than + reporting none: a wrong total fabricates unmapped positions that are not + there, or hides real ones. """ - if not occupied: + size = PANEL_SIZE_BY_MODEL.get(model.strip().upper()) + if size is None: + if model: + _LOGGER.warning( + "Unknown panel model %r; panel size unavailable and unmapped positions cannot be derived. Known models: %s", + model, + ", ".join(sorted(PANEL_SIZE_BY_MODEL)), + ) return 0 - return max(occupied) + return size + + +def panel_model_drift(panel: DiscoveredDevice) -> tuple[str, ...]: + """Models the panel says are valid that we have no size for. + + The panel advertises the model enum as a Homie ``$format`` on + ``info/model``, but nothing in the schema or the SDK states how many spaces + each model has — that half is ours. So the panel can legitimately announce + a model we cannot size, and this is how we find out at connect time rather + than through a user reporting missing positions. + + Same reasoning as the flat adapter's schema-drift detection: the failure is + a silent absence, so it needs a signal that does not depend on anyone + noticing an absence. + """ + definition = panel.get_node_properties(NODE_INFO).get(PROP_MODEL) + if not isinstance(definition, dict): + return () + advertised = str(definition.get("format", "")) + if not advertised: + return () + unknown = [ + value.strip() + for value in advertised.split(",") + if value.strip() and value.strip().upper() not in PANEL_SIZE_BY_MODEL + ] + if unknown: + _LOGGER.warning( + "Panel advertises model(s) %s that this adapter cannot size; " + "unmapped positions would be wrong for such a panel. Known: %s", + ", ".join(unknown), + ", ".join(sorted(PANEL_SIZE_BY_MODEL)), + ) + return tuple(unknown) + + +def build_unmapped_tabs(panel_size: int, occupied: set[int]) -> dict[str, SpanCircuitSnapshot]: + """Synthesise a zero-power entry for every unoccupied breaker position. + + The integration surfaces these as unmapped-circuit sensors, gated by its + own `enable_unmapped_circuit_sensors` option, and builds entity ids from + the circuit id — so the `unmapped_tab_` naming is a compatibility + contract with entities that already exist, not an internal detail. + + Reproducible under v1.0 only because the model gives a true total: the tree + itself lists occupied positions and says nothing about the rest. A panel + whose model is unrecognised yields nothing rather than a guess. + """ + unmapped: dict[str, SpanCircuitSnapshot] = {} + for tab in range(1, panel_size + 1): + if tab in occupied: + continue + circuit_id = f"{UNMAPPED_TAB_PREFIX}{tab}" + unmapped[circuit_id] = SpanCircuitSnapshot( + circuit_id=circuit_id, + name=f"Unmapped Tab {tab}", + relay_state="CLOSED", + instant_power_w=0.0, + produced_energy_wh=0.0, + consumed_energy_wh=0.0, + tabs=[tab], + priority=UNKNOWN, + is_user_controllable=False, + is_sheddable=False, + is_never_backup=False, + ) + return unmapped def find_lugs(devices: list[DiscoveredDevice], upstream: bool) -> DiscoveredDevice | None: diff --git a/tests/test_schema_one_panel.py b/tests/test_schema_one_panel.py index 704ba6d..9072de9 100644 --- a/tests/test_schema_one_panel.py +++ b/tests/test_schema_one_panel.py @@ -13,7 +13,13 @@ from ebus_sdk.homie import DiscoveredDevice -from span_panel_api_schema_1.panel import PanelFields, find_lugs, panel_size_from_tabs +from span_panel_api_schema_1.panel import ( + PanelFields, + build_unmapped_tabs, + find_lugs, + panel_model_drift, + panel_size_from_model, +) _TREE = json.loads((Path(__file__).parent / "fixtures" / "parent_child_tree.json").read_text(encoding="utf-8")) @@ -152,21 +158,83 @@ def test_retired_fields_are_none_rather_than_substituted(fields: PanelFields) -> # --------------------------------------------------------------------------- -# Panel size — the gap +# Panel size — from the model, because nothing else states it # --------------------------------------------------------------------------- -def test_panel_size_is_a_lower_bound_from_occupied_spaces() -> None: - """v1.0 publishes no panel size anywhere: `info/spaces` is a plain string - with no format, the panel device has no size property, and the migration - guide does not map one. The highest occupied space is the best available - answer and it undercounts.""" - assert panel_size_from_tabs([1, 3, 36, 38]) == 38 - assert panel_size_from_tabs([]) == 0 +def test_panel_size_comes_from_the_model() -> None: + """`info/model` is the only place v1.0 states the panel's size, and it is a + closed enum the panel itself advertises via Homie `$format`.""" + assert panel_size_from_model("MAIN_40") == 40 + assert panel_size_from_model("MAIN_16") == 16 + assert panel_size_from_model("MLO_48") == 48 -def test_panel_size_undercounts_a_sparsely_populated_panel() -> None: - """The failure this documents: a 40-space panel whose highest occupied slot - is 36 reports 36, so anything enumerating unoccupied slots is not - reproducible from the wire.""" - assert panel_size_from_tabs([1, 2, 36]) == 36 +def test_panel_size_reads_the_model_off_the_fixture() -> None: + panel = _device(PANEL) + + assert panel.get_property("info", "model") == "MAIN_40" + assert panel_size_from_model(panel.get_property("info", "model")) == 40 + + +def test_an_unknown_model_yields_no_size_rather_than_a_guess() -> None: + """Inventing a size is worse than reporting none: a wrong total fabricates + unmapped positions that do not exist, or hides real ones.""" + assert panel_size_from_model("MAIN_99") == 0 + assert panel_size_from_model("") == 0 + + +def test_the_panel_advertises_every_model_we_can_size(caplog: pytest.LogCaptureFixture) -> None: + """The panel publishes the valid model set as `$format`, but neither the + schema nor the SDK states the sizes — that half is ours. This is how a model + we cannot size shows up at connect time instead of as missing positions.""" + definition = _device(PANEL).get_node_properties("info")["model"] + assert definition["format"] == "MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48" + + assert panel_model_drift(_device(PANEL)) == () + + +def test_a_model_we_cannot_size_is_reported_as_drift() -> None: + panel = _device(PANEL) + description = json.loads(_TREE[PANEL]["$description"]) + description["nodes"]["info"]["properties"]["model"]["format"] = "MAIN_40,MAIN_64" + panel.update_description(json.dumps(description)) + + assert panel_model_drift(panel) == ("MAIN_64",) + + +# --------------------------------------------------------------------------- +# Unmapped positions — reproducible under v1.0 only because the model gives a total +# --------------------------------------------------------------------------- + + +def test_unmapped_tabs_fill_every_unoccupied_position() -> None: + unmapped = build_unmapped_tabs(panel_size=6, occupied={1, 3}) + + assert sorted(unmapped) == [ + "unmapped_tab_2", + "unmapped_tab_4", + "unmapped_tab_5", + "unmapped_tab_6", + ] + assert unmapped["unmapped_tab_2"].tabs == [2] + assert unmapped["unmapped_tab_2"].instant_power_w == 0.0 + assert unmapped["unmapped_tab_2"].name == "Unmapped Tab 2" + + +def test_the_unmapped_id_format_matches_the_flat_adapter() -> None: + """The integration builds entity ids from this — `sensor.span_panel_ + unmapped_tab_32_power` — so a rename would strand existing entities.""" + unmapped = build_unmapped_tabs(panel_size=32, occupied=set(range(1, 32))) + + assert list(unmapped) == ["unmapped_tab_32"] + + +def test_a_fully_occupied_panel_has_no_unmapped_positions() -> None: + assert build_unmapped_tabs(panel_size=4, occupied={1, 2, 3, 4}) == {} + + +def test_an_unsizable_panel_yields_no_unmapped_positions() -> None: + """Better nothing than a fabricated set: size 0 is what an unknown model + reports, and inventing positions would create phantom entities.""" + assert build_unmapped_tabs(panel_size=0, occupied={1}) == {} From eb1b2840ffae9ecd268df8e02f4fd21cb681262d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:47:17 -0700 Subject: [PATCH 027/115] feat(schema_1): map the DERs and assemble a full snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the mapper's read side: BESS, PV and EVSE, plus the assembly that turns a discovered tree into a SpanPanelSnapshot. Two DER mappings are deliberately not the obvious one, because v1.0 changed what a name means rather than only where it lives — and both alternatives produce a plausible value that silently means something else. The BESS model/part-number swap: flat bess/model was the SKU, v1.0 info/model is the human designation, and the SKU moved to the new info/part-number. So the designation goes to product_name and the SKU to model. Mapping info/model onto model would have kept the entity and changed what it displays. Battery connectivity moved off the battery: battery.connected is the panel-side owner's connection/*-device-status, not anything the BESS says about itself. The BESS publishes status/communication-state, which looks like the right property and is a different signal the guide warns against conflating. It also stays None when nothing claims the device, so "nobody has said" does not read as "not OK" while an owner is still announcing. Assembly sorts the tree by declared device type, never by device id: the reference tree's ids are the simulator's naming while the type string is what the schema defines. Lugs match on prefix because firmware may declare the base type with a direction property or a subtype, and the flat adapter already had to handle both. dsm_state and current_run_config are left UNKNOWN rather than reconstructed. The flat adapter derives them from several v2 signals, two of which — dominant-power-source and grid-islandable — no longer exist, so running the heuristic against missing inputs would produce a confident wrong answer. pv.relative_position is left None for the same reason: it is retired and only "derivable from connection records (when present)", and the integration gates control entities on it, so a wrong value creates or removes a control. 511 tests pass, coverage 94%. --- .../src/span_panel_api_schema_1/devices.py | 143 +++++++++++++++ .../src/span_panel_api_schema_1/snapshot.py | 155 ++++++++++++++++ tests/test_schema_one_devices.py | 169 ++++++++++++++++++ tests/test_schema_one_snapshot.py | 136 ++++++++++++++ 4 files changed, 603 insertions(+) create mode 100644 packages/schema-1/src/span_panel_api_schema_1/devices.py create mode 100644 packages/schema-1/src/span_panel_api_schema_1/snapshot.py create mode 100644 tests/test_schema_one_devices.py create mode 100644 tests/test_schema_one_snapshot.py diff --git a/packages/schema-1/src/span_panel_api_schema_1/devices.py b/packages/schema-1/src/span_panel_api_schema_1/devices.py new file mode 100644 index 0000000..f08c7c1 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/devices.py @@ -0,0 +1,143 @@ +"""Map the BESS, PV and EVSE devices onto their snapshot dataclasses. + +Two mappings here are deliberately *not* the obvious one, because v1.0 changed +what a name means rather than only where it lives. Both would produce a +plausible value that silently means something else: + +**The BESS model/part-number swap.** Flat ``bess/model`` was the SKU +(``1232100-00-E``); v1.0's ``info/model`` is the human designation +(``Powerwall 2 AC``) and the SKU moved to the new ``info/part-number``. Mapping +``info/model`` onto ``battery.model`` would keep the entity and change what it +displays, so the SKU is taken from ``part-number`` and the designation from +``model``. + +**Battery connectivity moved off the battery.** ``battery.connected`` is now the +panel-side owner's ``connection/*-device-status``, not anything the BESS +publishes about itself. The BESS's own ``status/communication-state`` looks like +the right property and is a different signal — the migration guide warns +against conflating them. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from span_panel_api.models import SpanBatterySnapshot, SpanEvseSnapshot, SpanPVSnapshot +from span_panel_api_schema_1.const import NODE_CONNECTION, NODE_INFO, NODE_METER, NODE_SOC, NODE_STATUS, NODE_SWITCH, UNKNOWN +from span_panel_api_schema_1.panel import number, text + +if TYPE_CHECKING: + from ebus_sdk.homie import DiscoveredDevice + +PROP_FIRMWARE_VERSION = "firmware-version" +PROP_MODEL = "model" +PROP_NAMEPLATE_CAPACITY = "nameplate-capacity" +PROP_NOMINAL_POWER = "nominal-power" +PROP_PART_NUMBER = "part-number" +PROP_SERIAL_NUMBER = "serial-number" +PROP_VENDOR_NAME = "vendor-name" + +PROP_SOC = "soc" +PROP_SOE = "soe" + +PROP_ADVERTISED_CURRENT = "advertised-current" +PROP_LOCK_STATE = "lock-state" +PROP_STATUS = "status" + +PROP_FEEDS_DEVICE_ID = "feeds-device-id" +PROP_FED_BY_DEVICE_ID = "fed-by-device-id" +PROP_FED_BY_DEVICE_STATUS = "fed-by-device-status" + +STATUS_OK = "OK" + + +def _optional(value: str) -> str | None: + """Empty means the panel did not publish it, which is not the same as ''.""" + return value or None + + +def feed_circuit_ids(circuits: list[DiscoveredDevice]) -> dict[str, str]: + """Map each fed device id to the circuit that feeds it. + + v1.0 states the relationship on the *circuit* (``connection/feeds-device-id``) + rather than on the DER, so it is read once here and handed to whichever + device needs it. + """ + feeds: dict[str, str] = {} + for circuit in circuits: + fed = text(circuit, NODE_CONNECTION, PROP_FEEDS_DEVICE_ID) + if fed: + feeds[fed] = circuit.device_id + return feeds + + +def connection_status_for(device_id: str, owners: list[DiscoveredDevice]) -> str | None: + """The connection status a panel-side owner reports *about* `device_id`. + + This is where ``battery.connected`` comes from. Returns None when nothing + claims the device, which is the honest answer for a panel whose owner has + not announced yet — distinct from a device that is known-disconnected. + """ + for owner in owners: + if text(owner, NODE_CONNECTION, PROP_FED_BY_DEVICE_ID) == device_id: + return _optional(text(owner, NODE_CONNECTION, PROP_FED_BY_DEVICE_STATUS)) + return None + + +def build_battery(bess: DiscoveredDevice | None, owners: list[DiscoveredDevice]) -> SpanBatterySnapshot: + """Build the battery snapshot. An uncommissioned panel yields the empty one.""" + if bess is None: + return SpanBatterySnapshot() + + status = connection_status_for(bess.device_id, owners) + + return SpanBatterySnapshot( + # Historically misnamed in the snapshot and kept that way: `soe_percentage` + # holds the percentage (`soc/soc`) and `soe_kwh` the energy (`soc/soe`). + # Renaming would break dashboards for a cosmetic gain. + soe_percentage=number(bess, NODE_SOC, PROP_SOC), + soe_kwh=number(bess, NODE_SOC, PROP_SOE), + vendor_name=_optional(text(bess, NODE_INFO, PROP_VENDOR_NAME)), + # The swap: designation to product_name, SKU to model. + product_name=_optional(text(bess, NODE_INFO, PROP_MODEL)), + model=_optional(text(bess, NODE_INFO, PROP_PART_NUMBER)), + serial_number=_optional(text(bess, NODE_INFO, PROP_SERIAL_NUMBER)), + software_version=_optional(text(bess, NODE_INFO, PROP_FIRMWARE_VERSION)), + nameplate_capacity_kwh=number(bess, NODE_INFO, PROP_NAMEPLATE_CAPACITY), + # None when unclaimed, so "nobody has said" stays distinct from "not OK". + connected=None if status is None else status == STATUS_OK, + ) + + +def build_pv(pv: DiscoveredDevice | None, feeds: dict[str, str]) -> SpanPVSnapshot: + """Build the PV snapshot. An uncommissioned panel yields the empty one.""" + if pv is None: + return SpanPVSnapshot() + + return SpanPVSnapshot( + vendor_name=_optional(text(pv, NODE_INFO, PROP_VENDOR_NAME)), + product_name=_optional(text(pv, NODE_INFO, PROP_MODEL)), + nameplate_capacity_w=number(pv, NODE_INFO, PROP_NOMINAL_POWER), + feed_circuit_id=feeds.get(pv.device_id), + # `relative-position` is retired in v1.0 and the guide is explicit that + # it is only "derivable from connection records (when present)". Left + # None rather than guessed: the integration gates control entities on + # it, so a wrong value creates or removes a control. + relative_position=None, + ) + + +def build_evse(evse: DiscoveredDevice, feeds: dict[str, str]) -> SpanEvseSnapshot: + """Build one EVSE snapshot.""" + return SpanEvseSnapshot( + node_id=evse.device_id, + feed_circuit_id=feeds.get(evse.device_id, ""), + status=text(evse, NODE_STATUS, PROP_STATUS, UNKNOWN), + lock_state=text(evse, NODE_SWITCH, PROP_LOCK_STATE, UNKNOWN), + advertised_current_a=number(evse, NODE_METER, PROP_ADVERTISED_CURRENT), + vendor_name=_optional(text(evse, NODE_INFO, PROP_VENDOR_NAME)), + product_name=_optional(text(evse, NODE_INFO, PROP_MODEL)), + part_number=_optional(text(evse, NODE_INFO, PROP_PART_NUMBER)), + serial_number=_optional(text(evse, NODE_INFO, PROP_SERIAL_NUMBER)), + software_version=_optional(text(evse, NODE_INFO, PROP_FIRMWARE_VERSION)), + ) diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py new file mode 100644 index 0000000..d251ac2 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -0,0 +1,155 @@ +"""Assemble a ``SpanPanelSnapshot`` from a discovered v1.0 device tree. + +Sorting the tree into roles is the one job here, and it is done by **declared +device type**, never by device id. The reference tree's ids (``bess``, ``pv``, +``lugs-upstream``) are the simulator's naming; real firmware uses whatever it +likes, and the type string is what the schema defines. +""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +from span_panel_api.models import SpanPanelSnapshot +from span_panel_api_schema_1.circuits import build_circuit +from span_panel_api_schema_1.const import ( + NODE_INFO, + PROP_MODEL, + TYPE_BESS, + TYPE_CIRCUIT, + TYPE_EVSE, + TYPE_LUGS, + TYPE_MID, + TYPE_PV, + UNKNOWN, +) +from span_panel_api_schema_1.devices import build_battery, build_evse, build_pv, feed_circuit_ids +from span_panel_api_schema_1.panel import PanelFields, build_unmapped_tabs, find_lugs, panel_size_from_model, text + +if TYPE_CHECKING: + from ebus_sdk.homie import DiscoveredDevice + + +def device_type(device: DiscoveredDevice) -> str: + """The device's declared type from its description, or '' before it arrives. + + A device exists in the tree from the moment its parent names it as a child, + so an empty type is the normal mid-discovery state rather than an error. + """ + description: dict[str, object] = device.description or {} + declared = description.get("type") + return str(declared) if declared else "" + + +class TreeRoles: + """The tree sorted into the roles a snapshot needs. + + Matching is prefix-based for lugs, because firmware may declare either the + base ``…device.lugs`` type with a ``direction`` property or a subtyped + ``…device.lugs.upstream`` — the flat adapter already had to handle both + conventions, and there is no reason to assume v1.0 settled it. + """ + + def __init__(self, devices: list[DiscoveredDevice]) -> None: + self.circuits: list[DiscoveredDevice] = [] + self.lugs: list[DiscoveredDevice] = [] + self.evse: list[DiscoveredDevice] = [] + self.bess: DiscoveredDevice | None = None + self.pv: DiscoveredDevice | None = None + self.mid: DiscoveredDevice | None = None + + for device in devices: + declared = device_type(device) + if declared == TYPE_CIRCUIT: + self.circuits.append(device) + elif declared.startswith(TYPE_LUGS): + self.lugs.append(device) + elif declared == TYPE_EVSE: + self.evse.append(device) + elif declared == TYPE_BESS and self.bess is None: + self.bess = device + elif declared == TYPE_PV and self.pv is None: + self.pv = device + elif declared == TYPE_MID and self.mid is None: + self.mid = device + + +def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], ready_since: float = 0.0) -> SpanPanelSnapshot: + """Build a full snapshot from the panel and its descendants.""" + roles = TreeRoles(children) + upstream = find_lugs(roles.lugs, upstream=True) + downstream = find_lugs(roles.lugs, upstream=False) + fields = PanelFields(panel=panel, upstream_lugs=upstream, downstream_lugs=downstream, mid=roles.mid) + + feeds = feed_circuit_ids(roles.circuits) + # A DER's device type decides how its feeding circuit is labelled, so the + # circuit inherits it — matching the flat adapter, where the same circuit + # reports device_type "pv" rather than "circuit". + der_type_by_circuit = { + circuit_id: kind + for kind, device in (("pv", roles.pv), *(("evse", e) for e in roles.evse)) + if device is not None and (circuit_id := feeds.get(device.device_id)) + } + + circuits = {} + for circuit in roles.circuits: + snapshot = build_circuit(circuit, device_type=der_type_by_circuit.get(circuit.device_id, "circuit")) + circuits[snapshot.circuit_id] = snapshot + + occupied = {tab for circuit in circuits.values() for tab in circuit.tabs} + # Unoccupied positions are `total - occupied`, so this is only meaningful + # when the model gave a real total. An unknown model yields size 0 and no + # unmapped entries rather than a fabricated set. + panel_size = panel_size_from_model(text(panel, NODE_INFO, PROP_MODEL)) + circuits.update(build_unmapped_tabs(panel_size, occupied)) + + # Owners are every device that can claim a DER through a `connection` node. + owners = [*roles.lugs, *roles.circuits, panel] + + return SpanPanelSnapshot( + serial_number=fields.serial_number, + firmware_version=fields.firmware_version, + main_relay_state=fields.main_relay_state, + instant_grid_power_w=fields.instant_grid_power_w, + feedthrough_power_w=fields.feedthrough_power_w, + main_meter_energy_consumed_wh=fields.main_meter_energy_consumed_wh, + main_meter_energy_produced_wh=fields.main_meter_energy_produced_wh, + feedthrough_energy_consumed_wh=fields.feedthrough_energy_consumed_wh, + feedthrough_energy_produced_wh=fields.feedthrough_energy_produced_wh, + # Both are v1 fields the flat adapter derives from multiple v2 signals. + # Left UNKNOWN here rather than reproducing that heuristic against a + # schema whose inputs moved: `dominant-power-source` and + # `grid-islandable` — two of its three inputs — no longer exist. + dsm_state=UNKNOWN, + current_run_config=UNKNOWN, + door_state=fields.door_state, + # The panel has no proximity sensor property; the flat adapter reports + # authenticated-and-ready, and the same holds here. + proximity_proven=True, + uptime_s=int(time.monotonic() - ready_since) if ready_since > 0.0 else 0, + eth0_link=fields.eth0_link, + wlan_link=fields.wlan_link, + wwan_link=fields.wwan_link, + panel_size=panel_size, + dominant_power_source=fields.dominant_power_source, + grid_state=fields.grid_state, + grid_islandable=fields.grid_islandable, + l1_voltage=fields.l1_voltage, + l2_voltage=fields.l2_voltage, + main_breaker_rating_a=fields.main_breaker_rating_a, + wifi_ssid=fields.wifi_ssid, + vendor_cloud=fields.vendor_cloud, + power_flow_pv=fields.power_flow_pv, + power_flow_battery=fields.power_flow_battery, + power_flow_grid=fields.power_flow_grid, + power_flow_site=fields.power_flow_site, + upstream_l1_current_a=fields.upstream_l1_current_a, + upstream_l2_current_a=fields.upstream_l2_current_a, + downstream_l1_current_a=fields.downstream_l1_current_a, + downstream_l2_current_a=fields.downstream_l2_current_a, + circuits=circuits, + battery=build_battery(roles.bess, owners), + pv=build_pv(roles.pv, feeds), + evse={device.device_id: build_evse(device, feeds) for device in roles.evse}, + ) diff --git a/tests/test_schema_one_devices.py b/tests/test_schema_one_devices.py new file mode 100644 index 0000000..d09fcdc --- /dev/null +++ b/tests/test_schema_one_devices.py @@ -0,0 +1,169 @@ +"""BESS, PV and EVSE mapping from the v1.0 tree.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from ebus_sdk.homie import DiscoveredDevice + +from span_panel_api_schema_1.devices import ( + build_battery, + build_evse, + build_pv, + connection_status_for, + feed_circuit_ids, +) + +_TREE = json.loads((Path(__file__).parent / "fixtures" / "parent_child_tree.json").read_text(encoding="utf-8")) + +SOLAR_CIRCUIT = "573066aaddd7b75114c4563ce3af18c4" + + +def _device(device_id: str) -> DiscoveredDevice: + topics = _TREE[device_id] + device = DiscoveredDevice(device_id, "ebus") + device.update_description(topics["$description"]) + device.update_state(topics["$state"]) + for topic, value in topics.items(): + if topic.startswith("$"): + continue + node, _, prop = topic.partition("/") + if prop: + device.update_property(node, prop, value) + return device + + +def _circuits() -> list[DiscoveredDevice]: + return [_device(SOLAR_CIRCUIT), _device("0ab966b95f92a6a51ec548485aa85f54")] + + +# --------------------------------------------------------------------------- +# Topology — v1.0 states the relationship on the circuit, not the DER +# --------------------------------------------------------------------------- + + +def test_feed_relationships_are_read_off_the_circuits() -> None: + feeds = feed_circuit_ids(_circuits()) + + assert feeds == {"pv": SOLAR_CIRCUIT} + + +def test_connection_status_is_reported_by_the_owner_not_the_device() -> None: + """The upstream lugs claim the BESS, and it is their view of that link that + `battery.connected` reflects.""" + upstream = _device("lugs-upstream") + + assert upstream.get_property("connection", "fed-by-device-id") == "bess" + assert connection_status_for("bess", [upstream]) == "OK" + assert connection_status_for("pv", [upstream]) is None + + +# --------------------------------------------------------------------------- +# Battery +# --------------------------------------------------------------------------- + + +def test_battery_state_of_charge_and_energy() -> None: + battery = build_battery(_device("bess"), []) + + # Historically misnamed and kept that way: soe_percentage holds the + # percentage, soe_kwh the energy. + assert battery.soe_percentage == pytest.approx(50.4104, rel=1e-4) + assert battery.soe_kwh == pytest.approx(6.8054, rel=1e-4) + assert battery.nameplate_capacity_kwh == 13.5 + assert battery.vendor_name == "Span" + + +def test_battery_model_and_product_name_are_swapped_not_copied() -> None: + """Flat `bess/model` was the SKU; v1.0 `info/model` is the designation and + the SKU moved to `info/part-number`. Mapping info/model onto `model` would + keep the entity and change what it displays.""" + bess = _device("bess") + bess.update_property("info", "part-number", "1232100-00-E") + + battery = build_battery(bess, []) + + assert battery.product_name == "Example BESS" # designation + assert battery.model == "1232100-00-E" # SKU + + +def test_battery_connected_comes_from_the_owner_not_the_bess() -> None: + """The BESS publishes `status/communication-state`, which looks like the + right property and is a different signal. The guide warns against + conflating them.""" + bess = _device("bess") + assert bess.get_property("status", "communication-state") == "OK" + + assert build_battery(bess, [_device("lugs-upstream")]).connected is True + + +def test_battery_connected_is_none_when_nothing_claims_it() -> None: + """ "Nobody has said" is not the same as "not OK" — the latter would report a + healthy battery as disconnected while the owner is still announcing.""" + assert build_battery(_device("bess"), []).connected is None + + +def test_a_degraded_link_is_not_connected() -> None: + upstream = _device("lugs-upstream") + upstream.update_property("connection", "fed-by-device-status", "DEGRADED") + + assert build_battery(_device("bess"), [upstream]).connected is False + + +def test_no_bess_yields_the_empty_battery_snapshot() -> None: + battery = build_battery(None, []) + + assert battery.soe_percentage is None + assert battery.connected is None + + +# --------------------------------------------------------------------------- +# PV +# --------------------------------------------------------------------------- + + +def test_pv_metadata_and_feed() -> None: + pv = build_pv(_device("pv"), feed_circuit_ids(_circuits())) + + assert pv.vendor_name == "Enphase" + assert pv.product_name == "IQ8PLUS-72-2-US" + assert pv.nameplate_capacity_w == 10000.0 + assert pv.feed_circuit_id == SOLAR_CIRCUIT + + +def test_pv_relative_position_is_not_guessed() -> None: + """Retired in v1.0 and only "derivable from connection records (when + present)". The integration gates control entities on it, so a wrong value + creates or removes a control.""" + assert build_pv(_device("pv"), {}).relative_position is None + + +def test_no_pv_yields_the_empty_snapshot() -> None: + assert build_pv(None, {}).vendor_name is None + + +# --------------------------------------------------------------------------- +# EVSE +# --------------------------------------------------------------------------- + + +def test_evse_state_and_metadata() -> None: + evse = build_evse(_device("evse"), {}) + + assert evse.node_id == "evse" + assert evse.status == "CHARGING" + assert evse.lock_state == "LOCKED" + assert evse.advertised_current_a == 32.0 + assert evse.vendor_name == "SPAN" + assert evse.product_name == "SPAN Drive" + assert evse.part_number == "SPN-DRV-001" + assert evse.serial_number == "SIM-EVSE-example-40t-001" + + +def test_evse_without_a_feeding_circuit_reports_empty_not_none() -> None: + """`feed_circuit_id` is non-optional on the dataclass, so an unclaimed EVSE + gets the empty string rather than breaking construction.""" + assert build_evse(_device("evse"), {}).feed_circuit_id == "" diff --git a/tests/test_schema_one_snapshot.py b/tests/test_schema_one_snapshot.py new file mode 100644 index 0000000..229d2be --- /dev/null +++ b/tests/test_schema_one_snapshot.py @@ -0,0 +1,136 @@ +"""End-to-end snapshot assembly from the whole captured v1.0 tree.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from ebus_sdk.homie import DiscoveredDevice + +from span_panel_api.models import SpanPanelSnapshot +from span_panel_api_schema_1.snapshot import TreeRoles, build_snapshot + +_TREE = json.loads((Path(__file__).parent / "fixtures" / "parent_child_tree.json").read_text(encoding="utf-8")) + +PANEL = "example-40t-001" +SOLAR_CIRCUIT = "573066aaddd7b75114c4563ce3af18c4" + + +def _device(device_id: str) -> DiscoveredDevice: + topics = _TREE[device_id] + device = DiscoveredDevice(device_id, "ebus") + device.update_description(topics["$description"]) + device.update_state(topics["$state"]) + for topic, value in topics.items(): + if topic.startswith("$"): + continue + node, _, prop = topic.partition("/") + if prop: + device.update_property(node, prop, value) + return device + + +def _children() -> list[DiscoveredDevice]: + return [_device(device_id) for device_id in _TREE if device_id != PANEL] + + +@pytest.fixture(name="snapshot") +def _snapshot() -> SpanPanelSnapshot: + return build_snapshot(_device(PANEL), _children()) + + +def test_roles_are_sorted_by_declared_type_not_device_id() -> None: + """The reference tree's ids are the simulator's naming; the type string is + what the schema defines.""" + roles = TreeRoles(_children()) + + assert len(roles.circuits) == 5 + assert len(roles.lugs) == 2 + assert len(roles.evse) == 2 + assert roles.bess is not None and roles.bess.device_id == "bess" + assert roles.pv is not None and roles.pv.device_id == "pv" + assert roles.mid is not None and roles.mid.device_id == "bess-mid" + + +def test_snapshot_carries_panel_identity(snapshot: SpanPanelSnapshot) -> None: + assert snapshot.serial_number == "example-40t-001" + assert snapshot.panel_size == 40 + assert snapshot.main_breaker_rating_a == 200 + + +def test_every_real_circuit_is_present(snapshot: SpanPanelSnapshot) -> None: + real = {cid for cid in snapshot.circuits if not cid.startswith("unmapped_tab_")} + + assert len(real) == 5 + assert SOLAR_CIRCUIT in real + assert snapshot.circuits[SOLAR_CIRCUIT].name == "Solar Inverter" + + +def test_unoccupied_positions_are_filled_up_to_the_panel_size(snapshot: SpanPanelSnapshot) -> None: + """The feature the model lookup exists for: the tree lists occupied + positions and says nothing about the rest.""" + occupied = {tab for cid, c in snapshot.circuits.items() if not cid.startswith("unmapped_tab_") for tab in c.tabs} + unmapped = {cid for cid in snapshot.circuits if cid.startswith("unmapped_tab_")} + + assert len(occupied) + len(unmapped) == 40 + assert "unmapped_tab_40" in unmapped + # Occupied positions are never synthesised. + for tab in occupied: + assert f"unmapped_tab_{tab}" not in unmapped + + +def test_a_circuit_feeding_a_der_reports_the_der_type(snapshot: SpanPanelSnapshot) -> None: + """Matches the flat adapter, where a PV-feeding circuit reports device_type + 'pv' rather than 'circuit'.""" + assert snapshot.circuits[SOLAR_CIRCUIT].device_type == "pv" + + +def test_der_snapshots_are_populated(snapshot: SpanPanelSnapshot) -> None: + assert snapshot.battery.soe_percentage == pytest.approx(50.4104, rel=1e-4) + assert snapshot.battery.connected is True + assert snapshot.pv.product_name == "IQ8PLUS-72-2-US" + assert snapshot.pv.feed_circuit_id == SOLAR_CIRCUIT + assert set(snapshot.evse) == {"evse", "evse-2"} + assert snapshot.evse["evse"].status == "CHARGING" + + +def test_panel_and_lugs_values_reach_the_snapshot(snapshot: SpanPanelSnapshot) -> None: + assert snapshot.instant_grid_power_w == -5847.0 + assert snapshot.power_flow_pv == 8500.0 + assert snapshot.grid_state == "UP" + assert snapshot.l1_voltage == 120.0 + + +def test_derived_v1_fields_are_unknown_rather_than_reconstructed(snapshot: SpanPanelSnapshot) -> None: + """The flat adapter derives these from several v2 signals, two of which + (`dominant-power-source`, `grid-islandable`) no longer exist. Reproducing + the heuristic against missing inputs would produce a confident wrong + answer.""" + assert snapshot.dsm_state == "UNKNOWN" + assert snapshot.current_run_config == "UNKNOWN" + + +def test_an_unsizable_panel_yields_no_unmapped_positions() -> None: + """A panel whose model we cannot size must not fabricate positions.""" + panel = _device(PANEL) + panel.update_property("info", "model", "MAIN_99") + + snapshot = build_snapshot(panel, _children()) + + assert snapshot.panel_size == 0 + assert not [cid for cid in snapshot.circuits if cid.startswith("unmapped_tab_")] + # Real circuits survive — only the synthesised ones depend on the total. + assert len(snapshot.circuits) == 5 + + +def test_a_panel_with_no_children_still_builds() -> None: + """A panel mid-discovery has announced itself but no descendants yet.""" + snapshot = build_snapshot(_device(PANEL), []) + + assert snapshot.serial_number == "example-40t-001" + assert snapshot.instant_grid_power_w == 0.0 + assert snapshot.battery.soe_percentage is None + # Every position is unoccupied, so all 40 are synthesised. + assert len(snapshot.circuits) == 40 From 3638d79f38b1001513c1194c4cbf84257df3e13d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:55:25 -0700 Subject: [PATCH 028/115] feat(schema_1): the adapter and its field metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SchemaOneAdapter puts ebus_sdk.Controller behind the SchemaAdapter protocol. The SDK does the Homie work — walking $description.children, gating each child's subscription on its parent reaching ready, cascading state down the tree — while this adapter supplies the transport it parses over, sorts the result into a snapshot, and names the topics the transport publishes commands to. It never touches the connection. Verified against a live broker through the real transport path, not only replayed fixtures: ready, 5 real circuits plus 32 unmapped from a MAIN_40, grid -5847 W, battery 50.4% connected, PV and both EVSEs, 42 metadata fields, and a relay topic addressed at the circuit device. Field metadata is read from each device's $description rather than the REST schema. The migration guide is explicit that the description is authoritative per device, because the same capability type exposes different properties on different device classes — meter on the panel is voltage, on a circuit power and energy, on lugs both currents. The REST deviceClasses document is the superset across all hardware; the description is what this panel has. Fields the mapper declines have no metadata row. Advertising a unit for a reading that never arrives would have the integration validate against a field nothing populates. set_dominant_power_source_topic returns None rather than a plausible topic: the property split into grid-forming-entity and asserted-islanding-state, which are different controls on different devices. None makes the transport reject the command instead of publishing where nothing listens, and which successor to expose is a product decision. The pylint hook gains ebus-sdk for the same reason mypy did — its isolated environment reported import-error for a correctly declared dependency. Still no entry point: registration lands once this is exercised against a real panel rather than a simulator. 526 tests pass, coverage 94%. --- .pre-commit-config.yaml | 3 + .../src/span_panel_api_schema_1/__init__.py | 11 +- .../src/span_panel_api_schema_1/adapter.py | 184 +++++++++++++++++ .../src/span_panel_api_schema_1/const.py | 7 + .../span_panel_api_schema_1/field_metadata.py | 169 ++++++++++++++++ tests/test_schema_one_adapter.py | 187 ++++++++++++++++++ 6 files changed, 553 insertions(+), 8 deletions(-) create mode 100644 packages/schema-1/src/span_panel_api_schema_1/adapter.py create mode 100644 packages/schema-1/src/span_panel_api_schema_1/field_metadata.py create mode 100644 tests/test_schema_one_adapter.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fb5c050..fc54580 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -96,6 +96,9 @@ repos: - pytest - pyyaml - paho-mqtt + # schema-1 imports the eBus SDK; without it here the hook reports + # import-error for a dependency that is correctly declared. + - ebus-sdk>=0.17.0 exclude: '^src/span_panel_api/generated_client/.*|tests/.*|generate_client\.py|scripts/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|^examples/.*' # Check for common security issues diff --git a/packages/schema-1/src/span_panel_api_schema_1/__init__.py b/packages/schema-1/src/span_panel_api_schema_1/__init__.py index e60e53c..ba158d0 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/__init__.py +++ b/packages/schema-1/src/span_panel_api_schema_1/__init__.py @@ -1,11 +1,6 @@ -"""Parent/child schema (data-model-version 1.x) support for span-panel-api. - -Work in progress. This package does not yet register a `schema_1` adapter — see -the note in pyproject.toml. Until it can answer for a panel end to end, a 1.x -panel gets a clean SpanPanelAdapterMissingError rather than a late failure from -a half-built parser. -""" +"""Parent/child schema (data-model-version 1.x) parser for span-panel-api.""" +from span_panel_api_schema_1.adapter import SchemaOneAdapter from span_panel_api_schema_1.transport import ControllerRoutes -__all__ = ["ControllerRoutes"] +__all__ = ["ControllerRoutes", "SchemaOneAdapter"] diff --git a/packages/schema-1/src/span_panel_api_schema_1/adapter.py b/packages/schema-1/src/span_panel_api_schema_1/adapter.py new file mode 100644 index 0000000..16e6435 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/adapter.py @@ -0,0 +1,184 @@ +"""Parent/child adapter: `ebus_sdk.Controller` behind the `SchemaAdapter` protocol. + +The SDK does the Homie work — walking `$description.children`, gating each +child's subscription on its parent reaching `ready`, and cascading state down +the tree. This adapter supplies the transport it parses over, sorts the result +into a `SpanPanelSnapshot`, and builds the topics the transport publishes +commands to. + +**It never touches the connection.** `SchemaAdapter` instances are built before +one exists, so `Controller` is given a route table (`ControllerRoutes`) that +records its subscriptions instead of making them, and this adapter asks for one +broad subscription up front through `topics_to_subscribe()`. Every message then +arrives via `handle_message` and is routed to whichever SDK callback wanted it. +A reconnect re-subscribes that same static list, the broker replays the retained +tree, and the SDK repopulates — so there is no resync hook to wire or forget. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ebus_sdk import Controller + +from span_panel_api_schema_1.const import ( + HOMIE_DOMAIN, + HOMIE_VERSION, + NODE_INFO, + NODE_LOAD_SHED, + NODE_SWITCH, + PROP_NAME, + PROP_PRIORITY, + PROP_RELAY, + STATE_READY, +) +from span_panel_api_schema_1.field_metadata import build_field_metadata +from span_panel_api_schema_1.snapshot import TreeRoles, build_snapshot, device_type +from span_panel_api_schema_1.transport import ControllerRoutes + +if TYPE_CHECKING: + from collections.abc import Callable + + from ebus_sdk.homie import DiscoveredDevice + + from span_panel_api.models import FieldMetadata, SpanPanelSnapshot, V2HomieSchema + + +class SchemaOneAdapter: + """Parser for the parent/child schema (data-model-version 1.x).""" + + schema_major = "schema_1" + SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = (">=1.0", "<2.0") + + def __init__(self, serial_number: str, schema: V2HomieSchema) -> None: + self._serial_number = serial_number + self._schema = schema + self._routes = ControllerRoutes() + self._controller = Controller(root_device_id=serial_number, mqttc=self._routes) + self._property_callbacks: list[Callable[[str, str, str, str | None], None]] = [] + self._controller.set_on_property_changed_callback(self._on_property_changed) + # Records the subscriptions the tree walk needs; nothing reaches the + # wire, because this object has no connection to reach it with. + self._controller.start_discovery() + + # -- SchemaAdapter ----------------------------------------------------- + + def topics_to_subscribe(self) -> list[str]: + """One subscription covering the whole tree. + + Deliberately broader than the SDK's own per-device subscriptions, + because the adapter is asked this once at connect and again after a + reconnect — it has no way to add one when a child announces later. The + flat adapter takes the same approach with `ebus/5/{serial}/#`; here the + wildcard spans devices, since children are peers of the panel in the + topic tree rather than nodes beneath it. + """ + return [f"{HOMIE_DOMAIN}/{HOMIE_VERSION}/#"] + + def handle_message(self, topic: str, payload: str) -> None: + self._routes.dispatch(topic, payload) + + def is_ready(self) -> bool: + """Ready when the panel has announced itself and described its tree. + + Children are deliberately not required: a panel with a slow-announcing + child would otherwise never become ready, and the snapshot already + handles a partial tree. + """ + root = self._controller.get_root(self._serial_number) + return root is not None and root.state == STATE_READY and bool(root.description) + + def build_snapshot(self) -> SpanPanelSnapshot: + root = self._require_root() + return build_snapshot(root, self._children()) + + def build_field_metadata(self) -> dict[str, FieldMetadata]: + root = self._controller.get_root(self._serial_number) + devices = [] if root is None else [root, *self._children()] + return build_field_metadata(devices) + + def circuit_nodes_missing_names(self) -> list[str]: + """Circuits whose retained name has not arrived yet. + + The transport polls this during connect so the first snapshot carries + human-readable names rather than falling back to identifiers. + """ + return [ + circuit.device_id + for circuit in TreeRoles(self._children()).circuits + if not circuit.get_property(NODE_INFO, PROP_NAME) + ] + + def find_node_by_type(self, type_str: str) -> str | None: + """Return the id of the first device declaring `type_str`. + + Named for the flat schema's nodes; under parent/child the same question + is asked of devices, and the answer is a device id. + """ + for device in self._children(): + if device_type(device) == type_str: + return device.device_id + return None + + # -- Command topics ---------------------------------------------------- + # + # The adapter names the topic and the transport publishes it, so commanding + # a panel needs no connection here either. + + def set_circuit_relay_topic(self, circuit_id: str) -> str: + return self._set_topic(circuit_id, NODE_SWITCH, PROP_RELAY) + + def set_circuit_priority_topic(self, circuit_id: str) -> str: + return self._set_topic(circuit_id, NODE_LOAD_SHED, PROP_PRIORITY) + + def set_dominant_power_source_topic(self) -> str | None: + """No v1.0 equivalent, so no topic. + + `dominant-power-source` split into `grid-forming-entity` and + `asserted-islanding-state`, which are different controls on different + devices rather than a renamed one. Returning None makes the transport + reject the command instead of publishing to a topic nothing serves — + and which successor to expose is a product decision, tracked in the + entity and config deltas write-up. + """ + return None + + def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]: + """Subscribe to per-property updates; returns an unregister callable.""" + self._property_callbacks.append(callback) + + def _unregister() -> None: + if callback in self._property_callbacks: + self._property_callbacks.remove(callback) + + return _unregister + + # -- internals --------------------------------------------------------- + + def _set_topic(self, device_id: str, node: str, prop: str) -> str: + return f"{HOMIE_DOMAIN}/{HOMIE_VERSION}/{device_id}/{node}/{prop}/set" + + def _require_root(self) -> DiscoveredDevice: + """The root, or a clear error if discovery has not finished. + + Checks readiness rather than existence: `start_discovery` pre-creates + the root entry so descendants have somewhere to attach, so the device + object exists from construction and proves nothing on its own. + """ + root = self._controller.get_root(self._serial_number) + if root is None or not self.is_ready(): + raise RuntimeError(f"Device tree for {self._serial_number!r} is not ready; build_snapshot called too early") + return root + + def _children(self) -> list[DiscoveredDevice]: + return list(self._controller.get_descendants(self._serial_number)) + + def _on_property_changed(self, device_id: str, node_id: str, property_id: str, value: str, _old: str | None) -> None: + """Fan a Controller property change out to registered consumers. + + Signature adapts the SDK's five arguments to the protocol's four: the + protocol has no place for the previous value, and consumers that need + one keep it themselves. + """ + for callback in list(self._property_callbacks): + callback(device_id, node_id, property_id, value) diff --git a/packages/schema-1/src/span_panel_api_schema_1/const.py b/packages/schema-1/src/span_panel_api_schema_1/const.py index 2c25da7..f0abeeb 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/const.py +++ b/packages/schema-1/src/span_panel_api_schema_1/const.py @@ -70,6 +70,13 @@ PROP_MODEL = "model" +# Topic root. Children are peers of the panel in the topic tree rather than +# nodes beneath it, so a subscription covering the tree spans the domain. +HOMIE_DOMAIN = "ebus" +HOMIE_VERSION = "5" + +STATE_READY = "ready" + # Breaker spaces per panel model. # # This is the only source of the panel's total size in v1.0. The flat schema diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py new file mode 100644 index 0000000..911d053 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -0,0 +1,169 @@ +"""Build transport-agnostic field metadata from the v1.0 device tree. + +Maps every property the snapshot mapper reads to a snapshot field path, then +takes the declared unit and datatype for each from the tree itself. The result +is a dict the integration consumes without any Homie knowledge, keyed +``{snapshot_type}.{field_name}``. + +**Read from each device's ``$description``, not from the REST schema.** The +migration guide is explicit that "the authoritative property set for any +capability node is always declared in that device's ``$description``", because +the same capability type exposes different properties on different device +classes — ``meter`` on the panel is voltage, on a circuit is power and energy, +on a lugs device is both currents. The REST ``deviceClasses`` document is the +superset across all hardware; the description is what *this* panel actually has. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from span_panel_api.models import FieldMetadata +from span_panel_api_schema_1.const import ( + NODE_BREAKER, + NODE_DOOR, + NODE_INFO, + NODE_LOAD_SHED, + NODE_METER, + NODE_POWER_FLOWS, + NODE_SOC, + NODE_STATUS, + NODE_SWITCH, + TYPE_BESS, + TYPE_CIRCUIT, + TYPE_EVSE, + TYPE_LUGS, + TYPE_PANEL, + TYPE_PV, +) + +if TYPE_CHECKING: + from ebus_sdk.homie import DiscoveredDevice + +# (device type, node, property) → snapshot field path. +# +# Encodes how the mapper reads the tree, so it has to move with it. Where the +# mapper deliberately declines a value — dsm_state, current_run_config, +# dominant_power_source, grid_islandable, relative_position — there is no row, +# because metadata for a field nothing populates would advertise a unit for a +# reading that never arrives. +_PROPERTY_FIELD_MAP: tuple[tuple[str, str, str, str], ...] = ( + # --- Panel --------------------------------------------------------------- + (TYPE_PANEL, NODE_INFO, "firmware-version", "panel.firmware_version"), + (TYPE_PANEL, NODE_DOOR, "state", "panel.door_state"), + (TYPE_PANEL, NODE_STATUS, "relay", "panel.main_relay_state"), + (TYPE_PANEL, NODE_STATUS, "ethernet", "panel.eth0_link"), + (TYPE_PANEL, NODE_STATUS, "wifi", "panel.wlan_link"), + (TYPE_PANEL, NODE_STATUS, "cloud-connection", "panel.vendor_cloud"), + (TYPE_PANEL, NODE_METER, "voltage-a", "panel.l1_voltage"), + (TYPE_PANEL, NODE_METER, "voltage-b", "panel.l2_voltage"), + (TYPE_PANEL, NODE_BREAKER, "rating", "panel.main_breaker_rating_a"), + (TYPE_PANEL, NODE_POWER_FLOWS, "pv", "panel.power_flow_pv"), + (TYPE_PANEL, NODE_POWER_FLOWS, "battery", "panel.power_flow_battery"), + (TYPE_PANEL, NODE_POWER_FLOWS, "grid", "panel.power_flow_grid"), + (TYPE_PANEL, NODE_POWER_FLOWS, "site", "panel.power_flow_site"), + # --- Lugs → panel.* ------------------------------------------------------ + # One row per property, not per direction: both lugs devices declare the + # same type, and which is which comes from `info/direction` at read time. + (TYPE_LUGS, NODE_METER, "active-power", "panel.instant_grid_power_w"), + (TYPE_LUGS, NODE_METER, "imported-energy", "panel.main_meter_energy_consumed_wh"), + (TYPE_LUGS, NODE_METER, "exported-energy", "panel.main_meter_energy_produced_wh"), + (TYPE_LUGS, NODE_METER, "current-a", "panel.upstream_l1_current_a"), + (TYPE_LUGS, NODE_METER, "current-b", "panel.upstream_l2_current_a"), + # --- Circuit ------------------------------------------------------------- + (TYPE_CIRCUIT, NODE_INFO, "name", "circuit.name"), + (TYPE_CIRCUIT, NODE_INFO, "spaces", "circuit.tabs"), + (TYPE_CIRCUIT, NODE_SWITCH, "relay", "circuit.relay_state"), + (TYPE_CIRCUIT, NODE_SWITCH, "relay-requester", "circuit.relay_requester"), + (TYPE_CIRCUIT, NODE_SWITCH, "relay-controllable", "circuit.is_user_controllable"), + (TYPE_CIRCUIT, NODE_LOAD_SHED, "priority", "circuit.priority"), + (TYPE_CIRCUIT, NODE_METER, "active-power", "circuit.instant_power_w"), + (TYPE_CIRCUIT, NODE_METER, "current", "circuit.current_a"), + (TYPE_CIRCUIT, NODE_METER, "imported-energy", "circuit.produced_energy_wh"), + (TYPE_CIRCUIT, NODE_METER, "exported-energy", "circuit.consumed_energy_wh"), + (TYPE_CIRCUIT, NODE_BREAKER, "rating", "circuit.breaker_rating_a"), + (TYPE_CIRCUIT, NODE_BREAKER, "poles", "circuit.is_240v"), + # --- BESS ---------------------------------------------------------------- + (TYPE_BESS, NODE_SOC, "soc", "battery.soe_percentage"), + (TYPE_BESS, NODE_SOC, "soe", "battery.soe_kwh"), + (TYPE_BESS, NODE_INFO, "vendor-name", "battery.vendor_name"), + (TYPE_BESS, NODE_INFO, "model", "battery.product_name"), + (TYPE_BESS, NODE_INFO, "part-number", "battery.model"), + (TYPE_BESS, NODE_INFO, "nameplate-capacity", "battery.nameplate_capacity_kwh"), + # --- PV ------------------------------------------------------------------ + (TYPE_PV, NODE_INFO, "vendor-name", "pv.vendor_name"), + (TYPE_PV, NODE_INFO, "model", "pv.product_name"), + (TYPE_PV, NODE_INFO, "nominal-power", "pv.nameplate_capacity_w"), + # --- EVSE ---------------------------------------------------------------- + (TYPE_EVSE, NODE_STATUS, "status", "evse.status"), + (TYPE_EVSE, NODE_SWITCH, "lock-state", "evse.lock_state"), + (TYPE_EVSE, NODE_METER, "advertised-current", "evse.advertised_current_a"), +) + + +def build_field_metadata(devices: list[DiscoveredDevice]) -> dict[str, FieldMetadata]: + """Collect metadata for every mapped field the tree actually declares. + + A field with no declaring device is omitted rather than defaulted: the + integration compares these against its own sensor definitions, so an + invented unit would validate a reading the panel never sends. + """ + declared: dict[str, tuple[str | None, str]] = {} + for device in devices: + description: dict[str, object] = device.description or {} + device_type = str(description.get("type") or "") + if not device_type: + continue + for node_id, node in _nodes(description).items(): + for property_id, definition in _properties(node).items(): + declared[f"{device_type}|{node_id}|{property_id}"] = ( + _optional_str(definition.get("unit")), + str(definition.get("datatype") or "string"), + ) + + metadata: dict[str, FieldMetadata] = {} + for device_type, node_id, property_id, field_path in _PROPERTY_FIELD_MAP: + found = _lookup(declared, device_type, node_id, property_id) + if found is not None: + unit, datatype = found + metadata[field_path] = FieldMetadata(unit=unit, datatype=datatype) + return metadata + + +def _lookup( + declared: dict[str, tuple[str | None, str]], device_type: str, node_id: str, property_id: str +) -> tuple[str | None, str] | None: + """Find a declaration, allowing a device type to be a subtype of the mapped one. + + Lugs are the reason: firmware may declare `…device.lugs` or a subtyped + `…device.lugs.upstream`, and both carry the same properties. + """ + exact = declared.get(f"{device_type}|{node_id}|{property_id}") + if exact is not None: + return exact + suffix = f"|{node_id}|{property_id}" + for key, value in declared.items(): + if key.endswith(suffix) and key[: -len(suffix)].startswith(device_type): + return value + return None + + +def _nodes(description: dict[str, object]) -> dict[str, dict[str, object]]: + nodes = description.get("nodes") + if not isinstance(nodes, dict): + return {} + return {str(k): v for k, v in nodes.items() if isinstance(v, dict)} + + +def _properties(node: dict[str, object]) -> dict[str, dict[str, object]]: + properties = node.get("properties") + if not isinstance(properties, dict): + return {} + return {str(k): v for k, v in properties.items() if isinstance(v, dict)} + + +def _optional_str(value: object) -> str | None: + if value is None: + return None + text = str(value) + return text or None diff --git a/tests/test_schema_one_adapter.py b/tests/test_schema_one_adapter.py new file mode 100644 index 0000000..2beba3d --- /dev/null +++ b/tests/test_schema_one_adapter.py @@ -0,0 +1,187 @@ +"""The parent/child adapter behind the SchemaAdapter protocol. + +Driven by replaying the captured tree through `handle_message`, which is exactly +how the transport feeds it — so these exercise the SDK's real discovery path +(root ready, then each child) rather than a stubbed tree. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from span_panel_api.adapters import _derive_required_members +from span_panel_api.models import V2HomieSchema +from span_panel_api.protocol import SchemaAdapter +from span_panel_api_schema_1 import SchemaOneAdapter + +_TREE = json.loads((Path(__file__).parent / "fixtures" / "parent_child_tree.json").read_text(encoding="utf-8")) + +PANEL = "example-40t-001" +SOLAR_CIRCUIT = "573066aaddd7b75114c4563ce3af18c4" + + +def _schema() -> V2HomieSchema: + return V2HomieSchema( + firmware_version="spanos2/r202633/01", + types_schema_hash="sha256:test", + types={}, + data_model_version="1.0", + ) + + +def _feed(adapter: SchemaOneAdapter, device_ids: list[str] | None = None) -> None: + """Replay retained topics the way the broker would deliver them. + + The panel first, because the SDK gates a child's subscription on its + parent reaching `ready` — feeding children first would be discarded, which + is the behaviour, not a quirk of the test. + """ + for device_id in device_ids or [PANEL, *[d for d in _TREE if d != PANEL]]: + topics = _TREE[device_id] + prefix = f"ebus/5/{device_id}" + adapter.handle_message(f"{prefix}/$description", topics["$description"]) + adapter.handle_message(f"{prefix}/$state", topics["$state"]) + for topic, value in topics.items(): + if not topic.startswith("$"): + adapter.handle_message(f"{prefix}/{topic}", value) + + +@pytest.fixture(name="adapter") +def _adapter() -> SchemaOneAdapter: + adapter = SchemaOneAdapter(PANEL, _schema()) + _feed(adapter) + return adapter + + +def test_it_satisfies_the_schema_adapter_protocol() -> None: + missing = [m for m in _derive_required_members(SchemaAdapter) if not hasattr(SchemaOneAdapter, m)] + + assert missing == [] + assert SchemaOneAdapter.schema_major == "schema_1" + assert SchemaOneAdapter.SUPPORTS_DATA_MODEL_VERSIONS == (">=1.0", "<2.0") + + +def test_construction_touches_no_connection() -> None: + """The transport builds a parser before a connection exists, so this must + work with nothing to talk to.""" + adapter = SchemaOneAdapter(PANEL, _schema()) + + assert adapter.is_ready() is False + + +def test_one_broad_subscription_covers_the_whole_tree() -> None: + """Children are peers of the panel in the topic tree, so the wildcard spans + devices. The adapter is asked this once and cannot add more later.""" + assert SchemaOneAdapter(PANEL, _schema()).topics_to_subscribe() == ["ebus/5/#"] + + +def test_replaying_the_tree_makes_it_ready(adapter: SchemaOneAdapter) -> None: + assert adapter.is_ready() is True + + +def test_a_panel_that_never_becomes_ready_is_not_ready() -> None: + """The SDK gates child subscription on the parent's ready edge, so a + non-ready panel yields nothing — silently, which is why it is asserted.""" + adapter = SchemaOneAdapter(PANEL, _schema()) + prefix = f"ebus/5/{PANEL}" + adapter.handle_message(f"{prefix}/$description", _TREE[PANEL]["$description"]) + adapter.handle_message(f"{prefix}/$state", "disconnected") + + assert adapter.is_ready() is False + + +def test_snapshot_is_built_from_the_discovered_tree(adapter: SchemaOneAdapter) -> None: + snapshot = adapter.build_snapshot() + + assert snapshot.serial_number == PANEL + assert snapshot.panel_size == 40 + assert snapshot.circuits[SOLAR_CIRCUIT].name == "Solar Inverter" + assert snapshot.battery.soe_percentage == pytest.approx(50.4104, rel=1e-4) + # 5 circuits occupying 8 positions (two are multi-pole), so 32 remain. + assert len(snapshot.circuits) == 37 + + +def test_building_a_snapshot_before_discovery_fails_loudly() -> None: + adapter = SchemaOneAdapter(PANEL, _schema()) + + with pytest.raises(RuntimeError, match="not ready"): + adapter.build_snapshot() + + +# --------------------------------------------------------------------------- +# Field metadata +# --------------------------------------------------------------------------- + + +def test_field_metadata_takes_units_from_the_tree(adapter: SchemaOneAdapter) -> None: + metadata = adapter.build_field_metadata() + + assert metadata["circuit.instant_power_w"].unit == "W" + assert metadata["circuit.instant_power_w"].datatype == "float" + assert metadata["circuit.current_a"].unit == "A" + assert metadata["panel.l1_voltage"].unit == "V" + assert metadata["battery.soe_percentage"].unit == "%" + + +def test_field_metadata_omits_fields_the_mapper_declines(adapter: SchemaOneAdapter) -> None: + """Advertising a unit for a reading that never arrives would have the + integration validate against a field nothing populates.""" + metadata = adapter.build_field_metadata() + + assert "panel.dominant_power_source" not in metadata + assert "panel.grid_islandable" not in metadata + assert "pv.relative_position" not in metadata + + +def test_field_metadata_is_empty_before_discovery() -> None: + assert SchemaOneAdapter(PANEL, _schema()).build_field_metadata() == {} + + +# --------------------------------------------------------------------------- +# Commands — the adapter names the topic, the transport publishes it +# --------------------------------------------------------------------------- + + +def test_command_topics_address_the_child_device(adapter: SchemaOneAdapter) -> None: + """Under parent/child a circuit is its own device, so its command topic is + rooted at the circuit rather than nested under the panel.""" + assert adapter.set_circuit_relay_topic(SOLAR_CIRCUIT) == f"ebus/5/{SOLAR_CIRCUIT}/switch/relay/set" + assert adapter.set_circuit_priority_topic(SOLAR_CIRCUIT) == f"ebus/5/{SOLAR_CIRCUIT}/load-shed/priority/set" + + +def test_dominant_power_source_has_no_topic(adapter: SchemaOneAdapter) -> None: + """It split into two different controls on different devices. None makes + the transport reject the command rather than publish where nothing listens.""" + assert adapter.set_dominant_power_source_topic() is None + + +# --------------------------------------------------------------------------- +# Discovery helpers the transport uses +# --------------------------------------------------------------------------- + + +def test_circuits_missing_names_is_empty_once_retained_names_arrive(adapter: SchemaOneAdapter) -> None: + assert adapter.circuit_nodes_missing_names() == [] + + +def test_find_node_by_type_answers_with_a_device_id(adapter: SchemaOneAdapter) -> None: + assert adapter.find_node_by_type("energy.ebus.device.bess") == "bess" + assert adapter.find_node_by_type("energy.ebus.device.nonexistent") is None + + +def test_property_callbacks_receive_updates() -> None: + seen: list[tuple[str, str, str, str | None]] = [] + adapter = SchemaOneAdapter(PANEL, _schema()) + unregister = adapter.register_property_callback(lambda d, n, p, v: seen.append((d, n, p, v))) + + _feed(adapter, [PANEL]) + + assert any(node == "status" and prop == "relay" for _, node, prop, _ in seen) + + unregister() + before = len(seen) + _feed(adapter, [PANEL]) + assert len(seen) == before From 346cccfac67bd650305586a681a00a66354430ab Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:12:15 -0700 Subject: [PATCH 029/115] fix(schema_1): survive the broker's replay order and a half-arrived tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifying reconnect against a live broker turned up two defects that only a real replay exposes. Both reported themselves as a healthy connection, which is why neither showed up in review or against a fixture fed in a friendly order. A message arriving before the SDK had registered a route for it was dropped. Controller learns its topics as it walks the tree — the root's from construction, a child's only once the root reaches ready — but one wire subscription delivers the whole tree in a single burst, in whatever order the broker replays its retained store, and a broker is under no obligation to hand back a parent before its children. Seeded children-first, a 40-space panel parsed as zero circuits. The route table now holds an unrouted message and releases it when the matching route appears, which is the value a per-device subscription would have been given at subscribe time. Releases are re-entrant, so a whole tree unfolds from one root subscription; held messages are capped so an unclaimed subtree cannot leak in a process that runs for months. Readiness asked only about the root. Under the flat schema "described" and "complete" are the same event, because one $description carries the entire topology; under parent/child the root says ready while its children are still landing, so connect() returned with 4 of 37 circuits and no panel size. Readiness now waits for every declared device to describe itself, at any depth. Child state is deliberately not required — a commissioned DER that is currently offline publishes lost but keeps its retained description, and a panel should not fail to connect over an unplugged battery. The model is required only when the root's description declares it: panel size comes from nowhere else, and a zero erases every unmapped position rather than mis-stating a number, while asking only for what the panel promised keeps a firmware that omits it connectable. Values that land after the last description are handled where the flat schema already handles them. circuit_nodes_missing_names now also reports a DER missing the model it declared, so connect()'s existing soft wait covers the identity the integration registers an HA device from. scripts/verify_reconnect.py is what drives this: a severable TCP passthrough between client and broker, so the outage is a real network failure with the broker's retained state intact. Two outages, because recovery differs — one restored at once, which the reconnect loop absorbs with the parser instance intact, and one held past the rebuild threshold, which swaps in a fresh parser that has to repopulate from retained state alone. Both schemas pass all 19 checks: schema_0 against the simulator over TLS including a real CA re-fetch, schema_1 against a broker seeded from the captured tree. schema-1 joins the vulture and coverage gates, which it had been outside of. 537 tests pass, coverage 94%. --- .pre-commit-config.yaml | 4 +- .../src/span_panel_api_schema_1/adapter.py | 97 +++- .../src/span_panel_api_schema_1/transport.py | 75 ++- scripts/verify_reconnect.py | 530 ++++++++++++++++++ tests/test_schema_one_adapter.py | 105 +++- tests/test_schema_one_transport.py | 93 ++- 6 files changed, 880 insertions(+), 24 deletions(-) create mode 100644 scripts/verify_reconnect.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc54580..fac330e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -115,7 +115,7 @@ repos: hooks: - id: vulture name: vulture - entry: bash -c 'uv run vulture src/span_panel_api/ packages/schema-0/src/span_panel_api_schema_0/ --min-confidence 80' + entry: bash -c 'uv run vulture src/span_panel_api/ packages/schema-0/src/span_panel_api_schema_0/ packages/schema-1/src/span_panel_api_schema_1/ --min-confidence 80' language: system types: [python] pass_filenames: false @@ -138,6 +138,6 @@ repos: name: coverage summary entry: bash language: system - args: ['-c', 'output=$(uv run pytest tests/ --cov=src/span_panel_api --cov=packages/schema-0/src/span_panel_api_schema_0 --cov-config=pyproject.toml --cov-fail-under=85 -q 2>&1); status=$?; echo "$output"; exit "$status"'] + args: ['-c', 'output=$(uv run pytest tests/ --cov=src/span_panel_api --cov=packages/schema-0/src/span_panel_api_schema_0 --cov=packages/schema-1/src/span_panel_api_schema_1 --cov-config=pyproject.toml --cov-fail-under=85 -q 2>&1); status=$?; echo "$output"; exit "$status"'] pass_filenames: false verbose: true diff --git a/packages/schema-1/src/span_panel_api_schema_1/adapter.py b/packages/schema-1/src/span_panel_api_schema_1/adapter.py index 16e6435..e8aaff9 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/adapter.py +++ b/packages/schema-1/src/span_panel_api_schema_1/adapter.py @@ -17,6 +17,7 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING from ebus_sdk import Controller @@ -27,6 +28,7 @@ NODE_INFO, NODE_LOAD_SHED, NODE_SWITCH, + PROP_MODEL, PROP_NAME, PROP_PRIORITY, PROP_RELAY, @@ -43,6 +45,8 @@ from span_panel_api.models import FieldMetadata, SpanPanelSnapshot, V2HomieSchema +_LOGGER = logging.getLogger(__name__) + class SchemaOneAdapter: """Parser for the parent/child schema (data-model-version 1.x).""" @@ -56,6 +60,7 @@ def __init__(self, serial_number: str, schema: V2HomieSchema) -> None: self._routes = ControllerRoutes() self._controller = Controller(root_device_id=serial_number, mqttc=self._routes) self._property_callbacks: list[Callable[[str, str, str, str | None], None]] = [] + self._awaiting: tuple[str, ...] = () self._controller.set_on_property_changed_callback(self._on_property_changed) # Records the subscriptions the tree walk needs; nothing reaches the # wire, because this object has no connection to reach it with. @@ -79,14 +84,33 @@ def handle_message(self, topic: str, payload: str) -> None: self._routes.dispatch(topic, payload) def is_ready(self) -> bool: - """Ready when the panel has announced itself and described its tree. - - Children are deliberately not required: a panel with a slow-announcing - child would otherwise never become ready, and the snapshot already - handles a partial tree. + """Ready when the whole declared tree has described itself. + + The flat schema gets its entire topology in one `$description`, so + "described" and "complete" are the same event. Under parent/child the + topology arrives as one description per device, and the root's says + ready as soon as *its own* arrives — while its children are still + landing. Treating that as ready hands the transport a panel with a + handful of circuits and no model, which it reports as a healthy + connection. So readiness waits for every device the tree declares. + + Child *state* is deliberately not required. A commissioned DER that is + currently offline publishes `lost` but keeps its retained description, + and a panel should not fail to connect because a battery is unplugged. + + The model is required only when the root's description declares it: the + panel's size comes from nowhere else, and a snapshot built a moment too + early reports zero spaces, which erases every unmapped position rather + than merely mis-stating a number. Asking only for what the panel itself + promised keeps a firmware that omits the property connectable — it + falls back to the drift warning in `panel_size_from_model`. """ root = self._controller.get_root(self._serial_number) - return root is not None and root.state == STATE_READY and bool(root.description) + if root is None or root.state != STATE_READY or not root.description: + return False + if self._awaiting_descriptions(root): + return False + return self._model_arrived(root) def build_snapshot(self) -> SpanPanelSnapshot: root = self._require_root() @@ -98,16 +122,35 @@ def build_field_metadata(self) -> dict[str, FieldMetadata]: return build_field_metadata(devices) def circuit_nodes_missing_names(self) -> list[str]: - """Circuits whose retained name has not arrived yet. + """Devices whose retained identity has not arrived yet. The transport polls this during connect so the first snapshot carries - human-readable names rather than falling back to identifiers. + real names rather than falling back to identifiers. + + Readiness proves the tree's *shape* — every device the tree declares + has described itself. It cannot prove the tree's *labels*: a + description says which properties exist, and their retained values + arrive as separate messages that may land after the last description + does. That gap exists under the flat schema too; it just matters more + here, because a DER is its own device and the integration registers it + from this first snapshot. + + Named for the flat schema's circuits, where a missing name was the only + way to get a placeholder. Under parent/child every mapped device has + the same exposure, so a DER missing the model it declared is reported + alongside a circuit missing its name. """ - return [ - circuit.device_id - for circuit in TreeRoles(self._children()).circuits - if not circuit.get_property(NODE_INFO, PROP_NAME) - ] + roles = TreeRoles(self._children()) + missing = [circuit.device_id for circuit in roles.circuits if not circuit.get_property(NODE_INFO, PROP_NAME)] + ders = (roles.bess, roles.pv, *roles.evse) + missing.extend( + device.device_id + for device in ders + if device is not None + and PROP_MODEL in device.get_node_properties(NODE_INFO) + and device.get_property(NODE_INFO, PROP_MODEL) is None + ) + return missing def find_node_by_type(self, type_str: str) -> str | None: """Return the id of the first device declaring `type_str`. @@ -173,6 +216,34 @@ def _require_root(self) -> DiscoveredDevice: def _children(self) -> list[DiscoveredDevice]: return list(self._controller.get_descendants(self._serial_number)) + def _awaiting_descriptions(self, root: DiscoveredDevice) -> tuple[str, ...]: + """Devices the tree declares that have not described themselves yet. + + Walks declarations rather than discoveries, and at any depth: a child + may declare children of its own, and those count too. Logged when the + set changes, because the alternative diagnostic for a tree that never + completes is a bare 30-second connect timeout. + """ + described = {device.device_id: device for device in self._children() if device.description is not None} + awaiting = { + child_id + for device in (root, *described.values()) + for child_id in device.children_ids + if child_id not in described + } + pending = tuple(sorted(awaiting)) + if pending != self._awaiting: + self._awaiting = pending + if pending: + _LOGGER.debug("Waiting on %d declared devices: %s", len(pending), ", ".join(pending)) + return pending + + def _model_arrived(self, root: DiscoveredDevice) -> bool: + """Whether the panel has published the model it said it would.""" + if PROP_MODEL not in root.get_node_properties(NODE_INFO): + return True + return root.get_property(NODE_INFO, PROP_MODEL) is not None + def _on_property_changed(self, device_id: str, node_id: str, property_id: str, value: str, _old: str | None) -> None: """Fan a Controller property change out to registered consumers. diff --git a/packages/schema-1/src/span_panel_api_schema_1/transport.py b/packages/schema-1/src/span_panel_api_schema_1/transport.py index 43bc62c..ea86025 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/transport.py +++ b/packages/schema-1/src/span_panel_api_schema_1/transport.py @@ -21,10 +21,24 @@ SDK repopulates from it. There is no hand-wired ``resync`` hook to forget — which was the failure mode most likely to go unnoticed, because it produces stale readings rather than an error. + +One thing the single subscription does have to make up for. `Controller` learns +which topics it wants *as it goes*: the root's routes exist from construction, +a child's only once the root's description has been parsed and the root has +reached ready. But one wire subscription delivers the whole tree in a single +burst, in whatever order the broker replays its retained store — and a broker +is under no obligation to hand back the parent before its children. A message +that arrives before the SDK asks for it is therefore held, and delivered the +moment the matching route is registered. Under a real per-device subscription +the SDK would have got that value as a retained message at subscribe time, so +holding it reproduces what it would otherwise have seen rather than inventing +anything. Dropping it instead is silent and total: an entire panel parses as +zero circuits. """ from __future__ import annotations +import logging from typing import TYPE_CHECKING, Any from paho.mqtt.client import topic_matches_sub @@ -32,6 +46,15 @@ if TYPE_CHECKING: from collections.abc import Callable +_LOGGER = logging.getLogger(__name__) + +# Ceiling on messages held for a route that has not appeared. Sized well past a +# full panel — a 48-space enclosure with every DER runs to a few thousand +# topics — so reaching it means messages are arriving for a subtree the SDK +# will never ask about, and holding more would be a slow leak in a process that +# runs for months. +MAX_HELD_MESSAGES = 4096 + class ControllerRoutes: """Record `Controller`'s subscriptions and route messages back to them. @@ -43,6 +66,11 @@ class ControllerRoutes: def __init__(self) -> None: # Insertion-ordered; dispatch walks it in reverse — see dispatch(). self._routes: dict[str, Callable[[str, bytes], None]] = {} + # Last payload per topic that matched no route yet, keyed by topic so a + # newer value supersedes an older one — the same last-value-wins rule + # the broker applies to the retained message this stands in for. + self._held: dict[str, str] = {} + self._discarded = 0 # -- MqttControllerTransport ------------------------------------------- @@ -58,6 +86,10 @@ def publish(self, topic: str, data: str, qos: int = 1, retain: bool = False) -> state the user was trying to change, with the UI reporting they changed it. """ + # Named exactly as `MqttClient.publish` names them, so a real client + # satisfies the same protocol this class does. Discarded rather than + # renamed, because nothing here is ever sent. + del topic, data, qos, retain raise NotImplementedError( "ControllerRoutes is receive-only. Publish through the adapter's " "set_*_topic methods, which the transport layer sends for you." @@ -80,6 +112,7 @@ def subscribe(self, sub: str, param: Any, qos: int = 1) -> None: # pylint: disa # pattern behind whatever was added after it in dispatch's match order. self._routes.pop(sub, None) self._routes[sub] = param + self._release(sub, param) def unsubscribe(self, sub: str) -> None: """Forget the callback for `sub`. @@ -107,8 +140,11 @@ def dispatch(self, topic: str, payload: str) -> None: most recently recorded route costs nothing while overlap does not occur, and is correct if it ever does. - A topic matching no route is dropped: the wire subscription is broader - than the SDK's interest by construction. + A topic matching no route is held rather than dropped, because the + route it belongs to may simply not exist yet — see the module + docstring. The wire subscription is broader than the SDK's interest by + construction, so some held messages are never claimed; the ceiling + keeps that from growing without bound. The SDK hands callbacks `bytes`; the transport hands us `str`. """ @@ -116,8 +152,43 @@ def dispatch(self, topic: str, payload: str) -> None: if topic_matches_sub(sub, topic): self._routes[sub](topic, payload.encode()) return + self._hold(topic, payload) + + def _hold(self, topic: str, payload: str) -> None: + """Keep a message for a route that has not been registered yet.""" + if topic not in self._held and len(self._held) >= MAX_HELD_MESSAGES: + self._discarded += 1 + if self._discarded == 1: + _LOGGER.warning( + "Holding %d unrouted topics; discarding %r and further new ones. " + "The device tree is larger than expected, or the broker carries " + "topics outside it.", + MAX_HELD_MESSAGES, + topic, + ) + return + self._held[topic] = payload + + def _release(self, sub: str, callback: Callable[[str, bytes], None]) -> None: + """Deliver everything held that this newly registered route matches. + + Re-entrant by necessity: a released `$description` makes the SDK + subscribe to that device's children, which releases their held messages + in turn, so a whole tree unfolds from one root subscription. The + candidate list is therefore taken up front and each entry re-checked, + since a nested release may have claimed it already. + """ + for topic in [held for held in self._held if topic_matches_sub(sub, held)]: + payload = self._held.pop(topic, None) + if payload is not None: + callback(topic, payload.encode()) @property def routes(self) -> tuple[str, ...]: """The recorded subscription patterns, most recent last. Diagnostics only.""" return tuple(self._routes) + + @property + def held(self) -> int: + """Messages waiting for a route to be registered. Diagnostics only.""" + return len(self._held) diff --git a/scripts/verify_reconnect.py b/scripts/verify_reconnect.py new file mode 100644 index 0000000..8016be8 --- /dev/null +++ b/scripts/verify_reconnect.py @@ -0,0 +1,530 @@ +#!/usr/bin/env python3 +"""Verify that a live MQTT session recovers from a broker outage. + +The bridge's reconnect and rebuild machinery has unit coverage against a mocked +paho client, which proves the control flow. What it cannot prove is the part +only a broker can answer: that after a real socket drop the client +re-subscribes, the broker replays its retained tree, and the parser repopulates +to the same panel it described before. + +A severable TCP passthrough sits between the client and the broker, so the +outage is a real network failure — the broker keeps running and keeps its +retained state, exactly as when an integration loses its route to the panel. +Cutting closes the listener as well as the live sockets, so reconnect attempts +during the outage are refused rather than left hanging. + +Two outages are exercised, because the client recovers from them differently: + + brief Restored at once, so the reconnect loop succeeds before the + rebuild threshold. The parser instance survives, and has to + absorb a second delivery of the retained tree it already holds. + + sustained Held past MQTT_FULL_REBUILD_AFTER_FAILURES, so the bridge rebuilds + its paho client and the transport swaps in a *fresh* parser. + Recovery then comes entirely from retained state. + +Usage — flat schema against the SPAN simulator (TLS, real CA re-fetch): + + uv run python scripts/verify_reconnect.py \ + --serial sim-40t-001 \ + --panel-host 127.0.0.1 --panel-http-port 8081 \ + --broker-host 127.0.0.1 --broker-port 18883 \ + --broker-username span --broker-password + +Usage — parent/child schema against a plain broker seeded from the captured +tree. schema_1 registers no entry point yet, so its factory is named outright: + + uv run python scripts/verify_reconnect.py \ + --serial example-40t-001 \ + --broker-host 127.0.0.1 --broker-port 1883 --no-tls \ + --data-model-version 1.0 \ + --adapter span_panel_api_schema_1:SchemaOneAdapter \ + --seed tests/fixtures/parent_child_tree.json + +Exits non-zero if any check fails. +""" + +from __future__ import annotations + +import argparse +import asyncio +from collections.abc import Callable +import contextlib +from dataclasses import dataclass, field +import importlib +import json +from pathlib import Path +import socket +import sys +import time +from typing import TYPE_CHECKING + +from span_panel_api.exceptions import SpanPanelStaleDataError +from span_panel_api.models import SpanPanelSnapshot, V2HomieSchema +from span_panel_api.mqtt.client import SpanMqttClient +from span_panel_api.mqtt.const import MQTT_FULL_REBUILD_AFTER_FAILURES +from span_panel_api.mqtt.models import MqttClientConfig + +if TYPE_CHECKING: + from span_panel_api.protocol import SchemaAdapter + +# How long to allow for each stage. The rebuild threshold is three failures +# with 1s/2s/4s backoff, so a sustained outage needs headroom past ~7s. +DISCONNECT_TIMEOUT_S = 15.0 +REBUILD_TIMEOUT_S = 45.0 +RECOVERY_TIMEOUT_S = 60.0 +# Retained messages arrive in a burst on re-subscribe. Ongoing traffic is only +# distinguishable from that burst once it has drained. +BURST_DRAIN_S = 3.0 +LIVENESS_WINDOW_S = 5.0 + + +# --------------------------------------------------------------------------- +# The severable link +# --------------------------------------------------------------------------- + + +class SeverableLink: + """A TCP passthrough to the broker that can be cut and restored.""" + + def __init__(self, target_host: str, target_port: int) -> None: + self._target_host = target_host + self._target_port = target_port + self.port = _free_port() + self._server: asyncio.Server | None = None + self._live: set[asyncio.StreamWriter] = set() + + async def open(self) -> None: + """Start accepting connections on the reserved port.""" + self._server = await asyncio.start_server(self._handle, "127.0.0.1", self.port, reuse_address=True) + + async def cut(self) -> None: + """Refuse new connections and drop every live one. + + Sockets are closed before the listener is awaited: ``wait_closed`` also + waits for the handlers still pumping those sockets, so closing in the + other order deadlocks. + """ + for writer in list(self._live): + with contextlib.suppress(OSError): + writer.close() + self._live.clear() + server, self._server = self._server, None + if server is not None: + server.close() + with contextlib.suppress(Exception): + await asyncio.wait_for(server.wait_closed(), timeout=5.0) + + async def close(self) -> None: + await self.cut() + + async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + upstream_reader, upstream_writer = await asyncio.open_connection(self._target_host, self._target_port) + except OSError: + writer.close() + return + self._live.update({writer, upstream_writer}) + try: + await asyncio.gather( + self._pump(reader, upstream_writer), + self._pump(upstream_reader, writer), + ) + finally: + self._live.difference_update({writer, upstream_writer}) + + async def _pump(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + while chunk := await reader.read(65536): + writer.write(chunk) + await writer.drain() + except (OSError, asyncio.CancelledError): + pass + finally: + with contextlib.suppress(OSError): + writer.close() + + +def _free_port() -> int: + """Reserve a port number the link can rebind to after each cut.""" + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +# --------------------------------------------------------------------------- +# Reporting +# --------------------------------------------------------------------------- + + +@dataclass +class Report: + """Accumulated check results.""" + + checks: list[tuple[str, bool, str]] = field(default_factory=list) + + def check(self, name: str, ok: bool, detail: str = "") -> bool: + self.checks.append((name, ok, detail)) + print(f" [{'PASS' if ok else 'FAIL'}] {name}{f' — {detail}' if detail else ''}") + return ok + + @property + def failed(self) -> list[str]: + return [name for name, ok, _ in self.checks if not ok] + + +# --------------------------------------------------------------------------- +# Snapshot comparison +# --------------------------------------------------------------------------- + + +def _fingerprint(snapshot: SpanPanelSnapshot) -> dict[str, object]: + """Structure that must survive an outage unchanged. + + Deliberately excludes readings: power and energy are expected to move while + the client is away, and demanding they match would test the panel's + stability rather than the client's recovery. + """ + return { + "serial_number": snapshot.serial_number, + "panel_size": snapshot.panel_size, + "circuits": sorted( + (circuit_id, circuit.name, tuple(circuit.tabs), circuit.device_type) + for circuit_id, circuit in snapshot.circuits.items() + ), + "evse": sorted(snapshot.evse), + "battery_serial": snapshot.battery.serial_number, + "pv_product": snapshot.pv.product_name, + } + + +def _describe_difference(before: dict[str, object], after: dict[str, object]) -> str: + changed = [key for key in before if before[key] != after.get(key)] + if not changed: + return "identical" + return "; ".join(f"{key}: {_brief(before[key])} -> {_brief(after.get(key))}" for key in changed) + + +def _brief(value: object) -> str: + """A value short enough to read in a result line.""" + if isinstance(value, list): + return f"{len(value)} entries" if len(value) > 3 else repr(value) + return repr(value) + + +# --------------------------------------------------------------------------- +# Session +# --------------------------------------------------------------------------- + + +class Session: + """A connected client plus the observations the checks are made from.""" + + def __init__(self, client: SpanMqttClient) -> None: + self.client = client + self.edges: list[bool] = [] + self.dispatches = 0 + client.register_connection_callback(self.edges.append) + client.register_snapshot_callback(self._count) + + async def _count(self, _snapshot: SpanPanelSnapshot) -> None: + self.dispatches += 1 + + +async def _wait_for(predicate: Callable[[], bool], timeout: float, interval: float = 0.1) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + await asyncio.sleep(interval) + return predicate() + + +async def _snapshot_is_stale(client: SpanMqttClient) -> bool: + try: + await client.get_snapshot() + except SpanPanelStaleDataError: + return True + return False + + +async def _measure_liveness(session: Session) -> int: + """Count snapshot dispatches in a window past the retained burst.""" + await asyncio.sleep(BURST_DRAIN_S) + before = session.dispatches + await asyncio.sleep(LIVENESS_WINDOW_S) + return session.dispatches - before + + +# --------------------------------------------------------------------------- +# Scenarios +# --------------------------------------------------------------------------- + + +async def run_outage(session: Session, link: SeverableLink, report: Report, *, sustained: bool) -> None: + """Cut the link, verify the client notices, restore, verify it recovers.""" + label = "sustained" if sustained else "brief" + print(f"\n{label} outage") + + client = session.client + before = _fingerprint(await client.get_snapshot()) + adapter_before: SchemaAdapter | None = client.adapter + metadata_before = client.field_metadata + edges_before = len(session.edges) + + await link.cut() + + noticed = await _wait_for(lambda: len(session.edges) > edges_before, DISCONNECT_TIMEOUT_S) + report.check("client observes the outage", noticed and session.edges[edges_before] is False) + report.check("snapshots report stale data during the outage", await _snapshot_is_stale(client)) + + if sustained: + swapped = await _wait_for(lambda: client.adapter is not adapter_before, REBUILD_TIMEOUT_S) + report.check( + f"parser rebuilt after {MQTT_FULL_REBUILD_AFTER_FAILURES} failed reconnects", + swapped, + ) + # Checked before restoring: a fresh parser must be empty, and once the + # link is back the retained burst would fill it within milliseconds. + fresh = client.adapter + report.check( + "rebuilt parser starts empty", + fresh is not None and not fresh.is_ready(), + ) + else: + report.check( + "parser instance survives a brief outage", + client.adapter is adapter_before, + ) + + await link.open() + + reconnected = await _wait_for(lambda: len(session.edges) > edges_before + 1, RECOVERY_TIMEOUT_S) + report.check("client reconnects", reconnected and session.edges[-1] is True) + + adapter = client.adapter + ready = await _wait_for(lambda: adapter is not None and adapter.is_ready(), RECOVERY_TIMEOUT_S) + report.check("parser repopulates from retained state", ready) + + if not ready: + return + + after = _fingerprint(await client.get_snapshot()) + report.check( + "panel is described identically after recovery", + after == before, + _describe_difference(before, after), + ) + report.check( + "field metadata survives the outage", + client.field_metadata == metadata_before, + ) + + dispatched = await _measure_liveness(session) + report.check( + "live updates resume once the retained burst has drained", + dispatched > 0, + f"{dispatched} snapshots in {LIVENESS_WINDOW_S:.0f}s", + ) + + +async def check_callback_contract(session: Session, report: Report, *, rebuilt: bool) -> None: + """Property callbacks are registered on the parser, not the transport. + + A rebuild replaces the parser, so callbacks registered on the old instance + are gone — documented on ``SpanMqttClient.adapter`` and load-bearing for the + integration, which must re-register. Worth asserting rather than trusting. + """ + print("\nproperty callback contract") + adapter = session.client.adapter + if adapter is None: + report.check("adapter present", False) + return + + seen: list[str] = [] + unregister = adapter.register_property_callback(lambda device, node, prop, value: seen.append(node)) + received = await _wait_for(lambda: bool(seen), LIVENESS_WINDOW_S) + report.check( + "callbacks registered on the current parser receive updates", + received, + f"{len(seen)} updates", + ) + unregister() + + if rebuilt: + report.check( + "the parser that served the callback is the rebuilt one", + adapter is session.client.adapter, + ) + + +# --------------------------------------------------------------------------- +# Seeding a broker from a captured tree +# --------------------------------------------------------------------------- + + +async def seed_broker(fixture: Path, host: str, port: int, stop: asyncio.Event) -> None: + """Publish a captured device tree retained, then keep its meters moving. + + Stands in for a panel on a plain broker: the retained topics are what a + reconnecting client replays, and the ticking meters are what proves live + traffic resumed rather than merely the burst arriving. + """ + import paho.mqtt.client as paho # imported here so the flat path needs no seeder + + tree: dict[str, dict[str, str]] = json.loads(fixture.read_text(encoding="utf-8")) + client = paho.Client(callback_api_version=paho.CallbackAPIVersion.VERSION2) + client.connect(host, port, keepalive=60) + client.loop_start() + + for device_id, topics in tree.items(): + for topic, payload in topics.items(): + client.publish(f"ebus/5/{device_id}/{topic}", payload, qos=0, retain=True) + + meters = [ + (device_id, float(topics["meter/active-power"])) + for device_id, topics in tree.items() + if "meter/active-power" in topics + ] + print(f"seeded {sum(len(t) for t in tree.values())} retained topics for {len(tree)} devices, ticking {len(meters)} meters") + + tick = 0 + while not stop.is_set(): + tick += 1 + for device_id, base in meters: + client.publish(f"ebus/5/{device_id}/meter/active-power", f"{base + tick:.1f}", qos=0, retain=True) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(stop.wait(), timeout=1.0) + + client.loop_stop() + client.disconnect() + + +# --------------------------------------------------------------------------- +# Wiring +# --------------------------------------------------------------------------- + + +def _load_factory(spec: str) -> Callable[[str, V2HomieSchema], SchemaAdapter]: + """Resolve a ``module:attribute`` adapter factory. + + Needed while an adapter is deliberately unregistered: schema_1 ships no + entry point until it has run against real hardware, so its factory has to + be named to be exercised. + """ + module_name, _, attribute = spec.partition(":") + if not attribute: + raise SystemExit(f"--adapter expects 'module:attribute', got {spec!r}") + factory: Callable[[str, V2HomieSchema], SchemaAdapter] = getattr(importlib.import_module(module_name), attribute) + return factory + + +def _synthetic_schema(data_model_version: str | None) -> V2HomieSchema: + """A schema for brokers with no panel behind them. + + Only the discriminator matters here — the parser reads its structure from + the tree, and field metadata comes from each device's own description. + """ + return V2HomieSchema( + firmware_version="unknown", + types_schema_hash="sha256:synthetic", + types={}, + data_model_version=data_model_version, + ) + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--serial", required=True, help="Panel serial number (the Homie root device id)") + parser.add_argument("--broker-host", default="127.0.0.1") + parser.add_argument("--broker-port", type=int, required=True) + parser.add_argument("--broker-username", default="") + parser.add_argument("--broker-password", default="") + parser.add_argument("--no-tls", action="store_true", help="Plain TCP to the broker (no CA fetch)") + parser.add_argument("--panel-host", help="Panel HTTP host; when given, the schema is fetched from it") + parser.add_argument("--panel-http-port", type=int, default=80) + parser.add_argument( + "--data-model-version", + help="Discriminator to use when no panel is available to fetch a schema from", + ) + parser.add_argument("--adapter", help="Adapter factory as 'module:attribute'; omit to dispatch by entry point") + parser.add_argument("--seed", type=Path, help="Captured tree to publish retained before connecting") + parser.add_argument( + "--scenario", + choices=["brief", "sustained", "both"], + default="both", + ) + return parser.parse_args(argv) + + +async def _run(args: argparse.Namespace) -> int: + report = Report() + stop_seeder = asyncio.Event() + seeder: asyncio.Task[None] | None = None + + if args.seed is not None: + seeder = asyncio.create_task(seed_broker(args.seed, args.broker_host, args.broker_port, stop_seeder)) + await asyncio.sleep(2.0) # let the retained tree land before connecting + + link = SeverableLink(args.broker_host, args.broker_port) + await link.open() + print(f"link: 127.0.0.1:{link.port} -> {args.broker_host}:{args.broker_port}") + + config = MqttClientConfig( + broker_host="127.0.0.1", + username=args.broker_username, + password=args.broker_password, + mqtts_port=link.port, + use_tls=not args.no_tls, + ) + client = SpanMqttClient( + host=args.panel_host or args.broker_host, + serial_number=args.serial, + broker_config=config, + snapshot_interval=0.25, + panel_http_port=args.panel_http_port, + adapter_factory=_load_factory(args.adapter) if args.adapter else None, + schema=None if args.panel_host else _synthetic_schema(args.data_model_version), + ) + session = Session(client) + + try: + await client.connect() + # Snapshot dispatch is what the integration actually consumes, and it + # is gated on streaming — without this the liveness check measures + # nothing. + await client.start_streaming() + snapshot = await client.get_snapshot() + print( + f"connected: {client.schema_major} / {snapshot.serial_number} / " + f"{snapshot.panel_size} spaces / {len(snapshot.circuits)} circuits" + ) + + rebuilt = False + if args.scenario in ("brief", "both"): + await run_outage(session, link, report, sustained=False) + if args.scenario in ("sustained", "both"): + await run_outage(session, link, report, sustained=True) + rebuilt = True + await check_callback_contract(session, report, rebuilt=rebuilt) + finally: + await client.close() + await link.close() + if seeder is not None: + stop_seeder.set() + await seeder + + print() + if report.failed: + print(f"FAILED ({len(report.failed)}/{len(report.checks)}): {', '.join(report.failed)}") + return 1 + print(f"All {len(report.checks)} checks passed.") + return 0 + + +def main(argv: list[str] | None = None) -> int: + return asyncio.run(_run(_parse_args(argv))) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_schema_one_adapter.py b/tests/test_schema_one_adapter.py index 2beba3d..6ae0366 100644 --- a/tests/test_schema_one_adapter.py +++ b/tests/test_schema_one_adapter.py @@ -32,12 +32,14 @@ def _schema() -> V2HomieSchema: ) -def _feed(adapter: SchemaOneAdapter, device_ids: list[str] | None = None) -> None: +def _feed(adapter: SchemaOneAdapter, device_ids: list[str] | None = None, omit: tuple[str, ...] = ()) -> None: """Replay retained topics the way the broker would deliver them. - The panel first, because the SDK gates a child's subscription on its - parent reaching `ready` — feeding children first would be discarded, which - is the behaviour, not a quirk of the test. + The panel first by default, which is the friendly order — the SDK gates a + child's subscription on its parent reaching `ready`, so anything earlier + has no route yet. The transport holds those messages until the route + appears; `test_children_before_the_panel` covers the unfriendly order, + which a broker is equally entitled to replay. """ for device_id in device_ids or [PANEL, *[d for d in _TREE if d != PANEL]]: topics = _TREE[device_id] @@ -45,7 +47,7 @@ def _feed(adapter: SchemaOneAdapter, device_ids: list[str] | None = None) -> Non adapter.handle_message(f"{prefix}/$description", topics["$description"]) adapter.handle_message(f"{prefix}/$state", topics["$state"]) for topic, value in topics.items(): - if not topic.startswith("$"): + if not topic.startswith("$") and topic not in omit: adapter.handle_message(f"{prefix}/{topic}", value) @@ -82,6 +84,31 @@ def test_replaying_the_tree_makes_it_ready(adapter: SchemaOneAdapter) -> None: assert adapter.is_ready() is True +def test_children_before_the_panel_still_yields_the_whole_tree(adapter: SchemaOneAdapter) -> None: + """A broker replays its retained store in whatever order it likes. + + Found by the live reconnect check, not by review: seeded in this order the + panel parsed as ready with zero circuits — a complete, silent loss that + reported itself as a healthy connection. + """ + reversed_order = [*[d for d in _TREE if d != PANEL], PANEL] + late = SchemaOneAdapter(PANEL, _schema()) + _feed(late, reversed_order) + + assert late.is_ready() is True + assert _fingerprint(late) == _fingerprint(adapter) + + +def _fingerprint(adapter: SchemaOneAdapter) -> tuple[str, int, int, list[str]]: + snapshot = adapter.build_snapshot() + return ( + snapshot.serial_number, + snapshot.panel_size, + len(snapshot.circuits), + sorted(snapshot.evse), + ) + + def test_a_panel_that_never_becomes_ready_is_not_ready() -> None: """The SDK gates child subscription on the parent's ready edge, so a non-ready panel yields nothing — silently, which is why it is asserted.""" @@ -93,6 +120,56 @@ def test_a_panel_that_never_becomes_ready_is_not_ready() -> None: assert adapter.is_ready() is False +def test_a_root_whose_children_are_still_arriving_is_not_ready() -> None: + """The root reaches ready as soon as *its own* description lands. + + Trusting that hands the transport a panel with a few circuits and no model + — which it reports as a healthy connection. Found by the live reconnect + check: the first connect parsed 4 of 37 circuits and nothing said so. + """ + adapter = SchemaOneAdapter(PANEL, _schema()) + _feed(adapter, [PANEL]) + + assert adapter.is_ready() is False + + +def test_an_offline_child_does_not_block_readiness(adapter: SchemaOneAdapter) -> None: + """A commissioned DER that is unplugged publishes `lost` but keeps its + retained description. A panel must not fail to connect over it.""" + adapter.handle_message("ebus/5/bess/$state", "lost") + + assert adapter.is_ready() is True + + +def test_readiness_waits_for_the_model_the_panel_declared() -> None: + """Panel size comes from nowhere else, and a snapshot built a moment early + reports zero spaces — which erases every unmapped position rather than + mis-stating a number.""" + adapter = SchemaOneAdapter(PANEL, _schema()) + _feed(adapter, omit=("info/model",)) + + assert adapter.is_ready() is False + + adapter.handle_message(f"ebus/5/{PANEL}/info/model", _TREE[PANEL]["info/model"]) + + assert adapter.is_ready() is True + assert adapter.build_snapshot().panel_size == 40 + + +def test_a_panel_that_declares_no_model_still_connects() -> None: + """Waiting for a property the firmware never promised would make one + missing field fatal. The drift warning already covers the consequence.""" + description = json.loads(_TREE[PANEL]["$description"]) + del description["nodes"]["info"]["properties"]["model"] + adapter = SchemaOneAdapter(PANEL, _schema()) + adapter.handle_message(f"ebus/5/{PANEL}/$description", json.dumps(description)) + adapter.handle_message(f"ebus/5/{PANEL}/$state", _TREE[PANEL]["$state"]) + _feed(adapter, [d for d in _TREE if d != PANEL]) + + assert adapter.is_ready() is True + assert adapter.build_snapshot().panel_size == 0 + + def test_snapshot_is_built_from_the_discovered_tree(adapter: SchemaOneAdapter) -> None: snapshot = adapter.build_snapshot() @@ -167,6 +244,24 @@ def test_circuits_missing_names_is_empty_once_retained_names_arrive(adapter: Sch assert adapter.circuit_nodes_missing_names() == [] +def test_a_der_missing_its_declared_model_is_reported_alongside_circuits() -> None: + """Readiness proves the tree's shape, not its labels. + + A DER's identity arrives as its own retained message, which can land after + the last description — and the integration registers an HA device from the + first snapshot, so a placeholder there is permanent until reload. + """ + adapter = SchemaOneAdapter(PANEL, _schema()) + _feed(adapter, omit=("info/model",)) + adapter.handle_message(f"ebus/5/{PANEL}/info/model", _TREE[PANEL]["info/model"]) + + assert "pv" in adapter.circuit_nodes_missing_names() + + adapter.handle_message("ebus/5/pv/info/model", _TREE["pv"]["info/model"]) + + assert "pv" not in adapter.circuit_nodes_missing_names() + + def test_find_node_by_type_answers_with_a_device_id(adapter: SchemaOneAdapter) -> None: assert adapter.find_node_by_type("energy.ebus.device.bess") == "bess" assert adapter.find_node_by_type("energy.ebus.device.nonexistent") is None diff --git a/tests/test_schema_one_transport.py b/tests/test_schema_one_transport.py index b67fd61..52ceceb 100644 --- a/tests/test_schema_one_transport.py +++ b/tests/test_schema_one_transport.py @@ -14,6 +14,7 @@ from ebus_sdk import MqttControllerTransport from span_panel_api_schema_1 import ControllerRoutes +from span_panel_api_schema_1.transport import MAX_HELD_MESSAGES def test_it_satisfies_the_sdk_transport_protocol() -> None: @@ -81,9 +82,10 @@ def test_dispatch_delivers_bytes_to_the_matching_callback() -> None: callback.assert_called_once_with("ebus/5/panel/meter/active-power", b"-121.0") -def test_a_topic_matching_no_route_is_dropped() -> None: +def test_a_topic_matching_no_route_reaches_nobody_yet() -> None: """Expected, not exceptional: the wire subscription is broader than the - SDK's interest by construction.""" + SDK's interest by construction. It is held rather than delivered — see the + ordering tests below for why it is not simply thrown away.""" routes = ControllerRoutes() callback = MagicMock() routes.subscribe("ebus/5/panel/#", callback) @@ -91,6 +93,7 @@ def test_a_topic_matching_no_route_is_dropped() -> None: routes.dispatch("ebus/5/other-device/meter/active-power", "1.0") callback.assert_not_called() + assert routes.held == 1 def test_the_most_recently_recorded_matching_route_wins() -> None: @@ -142,3 +145,89 @@ def test_rerecording_a_pattern_replaces_it_and_moves_it_to_most_recent() -> None second.assert_called_once() first.assert_not_called() + + +# --------------------------------------------------------------------------- +# Arrival order +# +# One wire subscription delivers the whole tree at once, but the SDK registers +# its routes as it walks that tree. Anything arriving ahead of its route has to +# survive the gap, because the broker chooses the replay order and is under no +# obligation to hand back a parent before its children. +# --------------------------------------------------------------------------- + + +def test_a_message_that_arrives_before_its_route_is_delivered_when_the_route_appears() -> None: + routes = ControllerRoutes() + callback = MagicMock() + + routes.dispatch("ebus/5/child-a/meter/active-power", "-3500.0") + callback.assert_not_called() + + routes.subscribe("ebus/5/child-a/+/+", callback) + + callback.assert_called_once_with("ebus/5/child-a/meter/active-power", b"-3500.0") + assert routes.held == 0 + + +def test_a_held_topic_keeps_only_its_latest_value() -> None: + """The same last-value-wins rule the broker applies to the retained message + this stands in for. Delivering the stale reading too would be worse than + dropping it — the SDK would end on whichever arrived last.""" + routes = ControllerRoutes() + callback = MagicMock() + + routes.dispatch("ebus/5/child-a/meter/active-power", "-3500.0") + routes.dispatch("ebus/5/child-a/meter/active-power", "-3400.0") + routes.subscribe("ebus/5/child-a/+/+", callback) + + callback.assert_called_once_with("ebus/5/child-a/meter/active-power", b"-3400.0") + + +def test_releasing_a_message_can_register_the_routes_that_release_the_rest() -> None: + """How a whole tree unfolds from one root subscription. + + Releasing the root's description is what makes the SDK subscribe to its + children, whose own messages are already held — so release has to be + re-entrant, or the tree stops one level down. + """ + routes = ControllerRoutes() + child = MagicMock(name="child") + + def on_root(_topic: str, _payload: bytes) -> None: + routes.subscribe("ebus/5/child-a/+/+", child) + + routes.dispatch("ebus/5/child-a/meter/active-power", "-3500.0") + routes.dispatch("ebus/5/panel/$description", "{}") + + routes.subscribe("ebus/5/panel/$description", on_root) + + child.assert_called_once_with("ebus/5/child-a/meter/active-power", b"-3500.0") + assert routes.held == 0 + + +def test_a_released_message_is_not_delivered_again() -> None: + routes = ControllerRoutes() + callback = MagicMock() + routes.dispatch("ebus/5/child-a/meter/active-power", "-3500.0") + + routes.subscribe("ebus/5/child-a/+/+", callback) + routes.subscribe("ebus/5/child-a/+/+", callback) + + callback.assert_called_once() + + +def test_held_messages_stop_accumulating_at_the_ceiling() -> None: + """Unclaimed topics would otherwise be a slow leak in a process that runs + for months. Values already held still update — it is new topics that stop.""" + routes = ControllerRoutes() + + for index in range(MAX_HELD_MESSAGES + 10): + routes.dispatch(f"ebus/5/device-{index}/meter/active-power", "1.0") + routes.dispatch("ebus/5/device-0/meter/active-power", "2.0") + + assert routes.held == MAX_HELD_MESSAGES + + callback = MagicMock() + routes.subscribe("ebus/5/device-0/+/+", callback) + callback.assert_called_once_with("ebus/5/device-0/meter/active-power", b"2.0") From a92d323a652992d60ccdaffab76e54535cfb6df9 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:04:25 -0700 Subject: [PATCH 030/115] feat(schema_1): register the entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A panel reporting data-model-version 1.x now resolves to SchemaOneAdapter instead of the named SpanPanelAdapterMissingError. Held back until the parser could answer for a panel end to end — mapper, field metadata, command topics, and recovery from a real broker outage — which it now does. Installing the distribution remains the opt-in. A 1.x panel without span-panel-api-schema-1 still gets the error naming exactly what to install, so registration changes nothing for anyone who has not asked for it. The point at which it stops being opt-in is the integration's manifest, which is a separate decision and is not taken here. Three dispatch tests passed only because the key was unresolvable, so they would have gone green on an install that could never happen in the field: - The missing-adapter case now asks for schema_2, a major nothing provides. The assertion was always about the shape of the failure — named, with the installed set — not about which adapters happen to be absent today. - A 1.0 panel through create_span_client now pins that it resolves the parent/child parser rather than quietly falling back to the flat one, which is what the original test was defending against; a companion keeps the refusal path covered with a 2.0 panel. - The directly-constructed client asserts the same, because that path bypasses the factory and the integration uses it. 538 tests pass, coverage 94%. --- packages/schema-1/CHANGELOG.md | 36 +++++++++++++--- packages/schema-1/pyproject.toml | 17 ++++---- tests/test_factory_dispatch.py | 73 ++++++++++++++++++++++++-------- 3 files changed, 95 insertions(+), 31 deletions(-) diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index 6e0d8ca..1189d9d 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -4,13 +4,39 @@ All notable changes to `span-panel-api-schema-1` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is the parent/child device tree SPAN firmware `r202633+` publishes, identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version +number. A release here means this parser changed, never that the panel did. + +## [0.1.0b1] - 08/2026 + +Pre-release. First release as a standalone distribution, and the first parser for the parent/child data model. ### Added -- **`BridgeControllerTransport`** — an `ebus_sdk.MqttControllerTransport` over `AsyncMqttBridge`, so `Controller` parses the parent/child tree over span-panel-api's own connection to the panel's broker. Owns the per-subscription routing table the SDK's - client keeps internally, because our bridge has one message callback for the whole connection. +- **`SchemaOneAdapter`**, registered as `schema_1` under the `span_panel_api.schema_adapters` entry-point group. A panel reporting `data-model-version` `1.x` resolves to it; a panel without this package installed still gets the named + `SpanPanelAdapterMissingError`, so installing it is the opt-in. +- **`ControllerRoutes`** — an `ebus_sdk.MqttControllerTransport` that records `Controller`'s subscriptions instead of making them, so the SDK parses the tree over span-panel-api's own connection to the panel's broker. The adapter is built before a + connection exists and never receives one; a single wildcard subscription made by the transport layer covers the whole tree, and this routes each message to whichever SDK callback asked for it. +- **The snapshot mapper.** Sorts the tree by declared device type — never by device id — and maps it onto `SpanPanelSnapshot`: circuits, both lugs, the MID, and the BESS/PV/EVSE devices. +- **Panel size from `info/model`** via `PANEL_SIZE_BY_MODEL`, which is what restores the unmapped-position entries the integration builds from the difference between total and occupied spaces. `info/spaces` has no format and the panel publishes no size + property, so the model is the only source; `panel_model_drift()` reports a model the panel declares that we have no size for, because the alternative is a user noticing missing positions. +- **Field metadata read from each device's `$description`** rather than a schema document. The same capability type exposes different properties on different device classes — `meter` is voltage on the panel, power and energy on a circuit, both currents on + lugs — so the per-device description is what this panel actually has. +- **A `py.typed` marker**, so consumers type-check against this package's real annotations. + +### Known deviations and deliberate gaps + +- **`set_dominant_power_source_topic()` returns `None`.** The v1.0 property split into `grid-forming-entity` and `asserted-islanding-state`, which are different controls on different devices rather than a rename. `None` makes the transport reject the + command instead of publishing where nothing listens; which successor to expose is a product decision. +- **`dsm_state`, `current_run_config`, `grid_islandable` and `pv.relative_position`** have no direct v1.0 equivalent and are left to the product decisions tracked separately. Fields the mapper declines carry no metadata row, so the integration never + validates against a field nothing populates. + +### Fixed before first release -### Not yet +Both found by verifying reconnect against a live broker, and both presented as a healthy connection. -- No `schema_1` entry point is registered. Until this package can build a snapshot, a 1.x panel gets `SpanPanelAdapterMissingError` naming `schema_1` rather than a late failure from a partial parser. +- **Messages arriving before the SDK registered a route for them were dropped.** `Controller` learns its topics as it walks the tree, but one subscription delivers the whole tree at once in whatever order the broker replays its retained store. Seeded + children-first, a 40-space panel parsed as zero circuits. Unrouted messages are now held and released when the matching route appears — the value a per-device subscription would have been given at subscribe time — with a ceiling so an unclaimed subtree + cannot leak. +- **Readiness asked only about the root**, so a connection completed with a fraction of its circuits and no panel size. It now waits for every declared device to describe itself, at any depth. Child _state_ is deliberately not required, so an offline DER + does not block a connection; the model is required only when the root's description declares it. diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index f9ec1a6..1663bd7 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -21,16 +21,15 @@ dependencies = [ Homepage = "https://github.com/SpanPanel/span-panel-api" Issues = "https://github.com/SpanPanel/span-panel-api/issues" -# NO [project.entry-points."span_panel_api.schema_adapters"] BLOCK YET. +# The whole point of this distribution. Dispatch resolves `schema_1` for a 1.x +# panel by discovering this group, never by importing this package. # -# Deliberate, and the reason is the failure mode. Registering `schema_1` makes -# dispatch resolve it for a 1.x panel, which would then fail somewhere inside -# snapshot building — an opaque error, late, on a path the user cannot act on. -# Unregistered, the same panel gets the clean SpanPanelAdapterMissingError that -# Phase 2 Part A built, naming exactly what is missing. -# -# The entry point lands with the snapshot mapper (Phase 2 task 4), when this -# package can answer for a panel end to end. +# Held back until the parser could answer for a panel end to end — mapper, +# field metadata, command topics, and recovery from a real broker outage. +# Installing this package remains the opt-in: a 1.x panel without it still gets +# the named SpanPanelAdapterMissingError rather than a silent misparse. +[project.entry-points."span_panel_api.schema_adapters"] +schema_1 = "span_panel_api_schema_1:SchemaOneAdapter" [tool.uv.sources] span-panel-api = { workspace = true } diff --git a/tests/test_factory_dispatch.py b/tests/test_factory_dispatch.py index 34f1ecc..164ad96 100644 --- a/tests/test_factory_dispatch.py +++ b/tests/test_factory_dispatch.py @@ -6,6 +6,7 @@ import pytest from span_panel_api_schema_0 import SchemaZeroAdapter +from span_panel_api_schema_1 import SchemaOneAdapter from span_panel_api.adapters import _reset_adapter_cache from span_panel_api.exceptions import SpanPanelAdapterMissingError, SpanPanelSchemaVersionError from span_panel_api.dispatch import select_adapter_key @@ -76,14 +77,22 @@ def test_the_flat_key_is_the_one_the_transport_resolves() -> None: def test_missing_adapter_raises_with_the_installed_list() -> None: + """A panel whose schema outruns the install. + + Asks for a major nothing provides rather than `schema_1`, which this + workspace now installs. The assertion is about the shape of the failure — + named, with the installed set — not about which adapters happen to be + absent today. + """ from span_panel_api.adapters import resolve_adapter _reset_adapter_cache() with pytest.raises(SpanPanelAdapterMissingError) as exc: - resolve_adapter("schema_1", "data-model-version='1.0'") + resolve_adapter("schema_2", "data-model-version='2.0'") - assert exc.value.needed == "schema_1" + assert exc.value.needed == "schema_2" assert "schema_0" in exc.value.available + assert "schema_1" in exc.value.available # --------------------------------------------------------------------------- @@ -179,33 +188,60 @@ async def test_diagnostics_properties_before_and_after_connect(mqtt_client_mock: @pytest.mark.asyncio -async def test_a_parent_child_panel_is_refused_rather_than_parsed_as_flat() -> None: - """The bug Part A closes. +async def test_a_parent_child_panel_gets_the_parent_child_parser() -> None: + """The bug Part A closed, now that the parser it asks for exists. Before, `create_span_client` hardcoded `data_model_version = None`, so a panel reporting `1.0` was handed to the flat parser regardless of what it - said. Reverting the dispatch here shows what that cost: the flat parser - reaches for `energy.ebus.device.circuit/space`, which a parent/child - payload keeps under `deviceClasses`, and the run dies on + said. What that cost: the flat parser reaches for + `energy.ebus.device.circuit/space`, which a parent/child payload keeps + under `deviceClasses`, and the run dies on ValueError: Schema missing 'energy.ebus.device.circuit/space' property - — a message about a missing property, for a panel whose real problem is - that nothing installed can parse it. The panel is now refused by name - instead, naming the adapter to install and what is already there. + — a message about a missing property, for a panel whose real problem was + that nothing installed could parse it. Until `schema_1` registered, such a + panel was refused by name; now the name resolves, and this pins that it + resolves to the parent/child parser rather than quietly to the flat one. + """ + from span_panel_api.factory import create_span_client + + _reset_adapter_cache() + config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") + schema = parent_child_schema() + with ( + patch("span_panel_api.factory.SpanMqttClient") as mock_cls, + patch("span_panel_api.factory.get_homie_schema", return_value=schema), + ): + mock_cls.return_value.connect = AsyncMock() + await create_span_client("192.168.1.1", mqtt_config=config, serial_number="test-serial") + + _, kwargs = mock_cls.call_args + assert kwargs["adapter_factory"] is SchemaOneAdapter + assert kwargs["data_model_version"] == "1.0" + assert "1.0" in kwargs["schema_dispatch_reason"] + + +@pytest.mark.asyncio +async def test_a_panel_newer_than_the_install_is_refused_rather_than_parsed_as_flat() -> None: + """The other half of the same guarantee. + + A schema major nothing provides must be refused by name, not fall back to + whichever parser happens to be installed — which is the failure the flat + default used to produce, one major later. """ from span_panel_api.factory import create_span_client _reset_adapter_cache() config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") with ( - patch("span_panel_api.factory.get_homie_schema", return_value=parent_child_schema()), + patch("span_panel_api.factory.get_homie_schema", return_value=parent_child_schema("2.0")), pytest.raises(SpanPanelAdapterMissingError) as exc, ): await create_span_client("192.168.1.1", mqtt_config=config, serial_number="test-serial") - assert exc.value.needed == "schema_1" - assert "schema_0" in exc.value.available + assert exc.value.needed == "schema_2" + assert "schema_1" in exc.value.available @pytest.mark.asyncio @@ -216,15 +252,18 @@ async def test_a_directly_constructed_client_dispatches_too() -> None: documents direct construction, and the integration uses it. Before, that path always resolved the flat adapter, so it carried exactly the bug the factory path just had fixed. Dispatch now happens wherever a parser is - built. + built, which is what makes handing this client a 1.x schema produce a + parent/child parser rather than a flat one. """ _reset_adapter_cache() config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") client = SpanMqttClient("192.168.1.1", SERIAL, config) - with pytest.raises(SpanPanelAdapterMissingError) as exc: - client._build_adapter(parent_child_schema()) - assert exc.value.needed == "schema_1" + client._build_adapter(parent_child_schema()) + + assert isinstance(client.adapter, SchemaOneAdapter) + assert client.schema_major == "schema_1" + assert client.data_model_version == "1.0" def test_dispatch_records_what_it_read_on_the_client() -> None: From 5d0bf02a791d9d57c9b7ee295a36081fe4871b9a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:07:49 -0700 Subject: [PATCH 031/115] chore(schema_1): require ebus-sdk 0.18.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream released 0.18.0 with the fix for the issue we reported: the HA customizer's state-of-charge entry was keyed `battery`, which is not an eBus capability and never has been, so a conformant device typing its node `energy.ebus.capability.soc` missed the table entirely. No code impact here. We import `Controller` and `MqttControllerTransport` and never `ebus_sdk.ha`, so the customizer is not on our path, and the runtime API we do use is unchanged. The floor rises because we track the SDK, not because this release fixes something for us. Verified rather than assumed: 539 tests, and the live reconnect run passes all 19 checks against 0.18.0 with ebus-mqtt-client resolving to 0.4.0. Worth recording that the finding does not reach our fixture either — the captured tree already types the node `energy.ebus.capability.soc`, so the simulator never copied the non-conformant example the SDK's README carried. Upstream removed the `battery` key rather than aliasing it, so that would have been a live break rather than a deprecation. The release also documented the abstract `unit: "energy"` token, whose rule is that a concrete unit must come from the runtime `$description` and never from a catalog. Field metadata here already reads each device's own description, so we are conformant by construction; the exposure left is a panel that emits the abstract token itself, which would reach an entity as a literal unit string. A test asserts the captured tree carries none, on the same reasoning as the flat adapter's provenance tests: the symptom is a silent absence, so it needs a signal that does not depend on someone noticing it. --- .pre-commit-config.yaml | 4 ++-- packages/schema-1/pyproject.toml | 2 +- tests/test_schema_one_adapter.py | 22 ++++++++++++++++++++++ uv.lock | 8 ++++---- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fac330e..9f86906 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -80,7 +80,7 @@ repos: # schema-1 parses the parent/child tree with the eBus SDK, which # ships py.typed — so the hook needs it installed to resolve those # types rather than silently reporting import-not-found. - - ebus-sdk>=0.17.0 + - ebus-sdk>=0.18.0 args: ['--config-file=pyproject.toml'] exclude: '^src/span_panel_api/generated_client/.*|scripts/.*|tests/.*|docs/.*|examples/.*|\..*_cache/.*|dist/.*|venv/.*' @@ -98,7 +98,7 @@ repos: - paho-mqtt # schema-1 imports the eBus SDK; without it here the hook reports # import-error for a dependency that is correctly declared. - - ebus-sdk>=0.17.0 + - ebus-sdk>=0.18.0 exclude: '^src/span_panel_api/generated_client/.*|tests/.*|generate_client\.py|scripts/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|^examples/.*' # Check for common security issues diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index 1663bd7..75c7054 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ # schema-0 stay clean, so a flat-panel install never pulls it in — which is # what bounds the release coupling this dependency introduces to panels on # r202633+. - "ebus-sdk>=0.17.0,<1.0", + "ebus-sdk>=0.18.0,<1.0", ] [project.urls] diff --git a/tests/test_schema_one_adapter.py b/tests/test_schema_one_adapter.py index 6ae0366..4beeaed 100644 --- a/tests/test_schema_one_adapter.py +++ b/tests/test_schema_one_adapter.py @@ -203,6 +203,28 @@ def test_field_metadata_takes_units_from_the_tree(adapter: SchemaOneAdapter) -> assert metadata["battery.soe_percentage"].unit == "%" +def test_no_property_declares_an_abstract_unit() -> None: + """Units must reach Home Assistant renderable, not as a catalog token. + + eBus catalogs may carry an abstract `unit: "energy"` rather than a concrete + one, which a device resolves in its own `$description`. Reading the runtime + description is what keeps us clear of it — but only as long as the panel + resolves it too, and the symptom if it stops is an entity whose unit reads + the literal string. Asserted against the captured tree for the same reason + the flat adapter asserts its schema facts: a silent absence needs a signal + that does not depend on anyone noticing it. + """ + abstract = {"energy", "power", "current", "voltage"} + declared = { + properties.get("unit") + for device in _TREE.values() + for node in json.loads(device["$description"]).get("nodes", {}).values() + for properties in node.get("properties", {}).values() + } + + assert not declared & abstract, f"abstract unit tokens in the captured tree: {sorted(declared & abstract)}" + + def test_field_metadata_omits_fields_the_mapper_declines(adapter: SchemaOneAdapter) -> None: """Advertising a unit for a reading that never arrives would have the integration validate against a field nothing populates.""" diff --git a/uv.lock b/uv.lock index 2fe9c2e..c06fe71 100644 --- a/uv.lock +++ b/uv.lock @@ -504,14 +504,14 @@ wheels = [ [[package]] name = "ebus-sdk" -version = "0.17.0" +version = "0.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ebus-mqtt-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/82/064f03fbc9da08737d15f9f90ec01fd1c4e9d4d2e8ad17e2699951d20356/ebus_sdk-0.17.0.tar.gz", hash = "sha256:7014d2c73b5eb4befb59b0d9c682b6b56a46698c47201ae49fb61af3b99559bb", size = 141056, upload-time = "2026-08-03T00:25:34.475Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/ca/7709e30078ffa1efb9cb6c6fe30ac11fe36c58a4f5b1f4c8b10dee6386a1/ebus_sdk-0.18.0.tar.gz", hash = "sha256:14c08a5fe3d9338045aeb89eb889364760f215db7ddad94140cecd569b51dc44", size = 145076, upload-time = "2026-08-05T22:41:34.305Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/41/d3f6d5f2af755cce32614654a97eed421efac46fe4d20847643871b3e579/ebus_sdk-0.17.0-py3-none-any.whl", hash = "sha256:5232fa9990298b8785d092f7446e9b4a1a9d4a535a8422666c9b89c57d6248d0", size = 91032, upload-time = "2026-08-03T00:25:33.12Z" }, + { url = "https://files.pythonhosted.org/packages/97/a9/4c01ee7efadbac8bdbfe8b2db553ef12fc4264a360092fe58b5777f1260b/ebus_sdk-0.18.0-py3-none-any.whl", hash = "sha256:7aec63a2023d295ba6a256e6dba6da915a03c02facdd1d67c597bacaa15e6673", size = 92178, upload-time = "2026-08-05T22:41:32.957Z" }, ] [[package]] @@ -1400,7 +1400,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "ebus-sdk", specifier = ">=0.17.0,<1.0" }, + { name = "ebus-sdk", specifier = ">=0.18.0,<1.0" }, { name = "span-panel-api", editable = "." }, ] From 18bc7e7b26062dc67c1ac4c6d50e4a429072afef Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:31:16 -0700 Subject: [PATCH 032/115] fix(schema_1): grid_state is the MID's islanding-state, not its grid-state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MID publishes both, and the names invite the wrong choice. The flat schema's grid_state was the BESS's grid-state, an ON_GRID/OFF_GRID islanding answer; its v1.0 successor is grid/islanding-state, which carries that same vocabulary. The MID's own grid/grid-state answers a different question — whether the utility supply is UP, DOWN or DEGRADED — and is new in v1.0 with no flat equivalent. Mapping the similarly-named one is the exact failure the entity-delta write-up exists to catch: the entity keeps its id and its history while its state silently changes vocabulary, so every template comparing it stops being true without erroring. A live-broker run does not catch it either, because UP is a plausible string to see in a snapshot. Two tests asserted the wrong value, so they pinned the defect rather than finding it. A rename is verified by comparing value sets, not property names. The new test asserts the MID's two properties hold different values and that we take the islanding one, so the distinction is pinned rather than trusted. grid/grid-state stays unmapped: it is a genuinely new signal and belongs in a new sensor rather than grafted onto an existing field. 540 tests pass. --- .../src/span_panel_api_schema_1/panel.py | 18 ++++++++++- tests/test_schema_one_panel.py | 30 +++++++++++++++++-- tests/test_schema_one_snapshot.py | 2 +- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/panel.py b/packages/schema-1/src/span_panel_api_schema_1/panel.py index 5206ca2..4fcc5e4 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/panel.py +++ b/packages/schema-1/src/span_panel_api_schema_1/panel.py @@ -66,6 +66,11 @@ PROP_CURRENT_B = "current-b" PROP_GRID_STATE = "grid-state" +# The MID's islanding answer, and the true successor of the flat schema's +# `bess/grid-state`: same ON_GRID/OFF_GRID vocabulary. Kept next to +# PROP_GRID_STATE deliberately, because the two are easy to confuse and only +# one of them is what an existing consumer means by "grid state". +PROP_ISLANDING_STATE = "islanding-state" PROP_DIRECTION = "direction" DIRECTION_UPSTREAM = "UPSTREAM" @@ -262,7 +267,18 @@ def __init__( # Grid state moved to the MID device, which is where islanding is # actually decided. Absent when the panel has no MID. - self.grid_state = text(mid, NODE_GRID, PROP_GRID_STATE) or None + # + # **From `islanding-state`, not from `grid-state`.** The MID publishes + # both, and the names invite exactly the wrong choice: the flat schema's + # `grid_state` was the BESS's `grid-state`, an ON_GRID/OFF_GRID + # islanding answer, and its v1.0 successor is `grid/islanding-state` + # with that same value set. The MID's own `grid/grid-state` is a + # different question — whether the utility supply is UP, DOWN or + # DEGRADED — and is new in v1.0 with no flat equivalent. Matching on + # the property name puts UP where consumers expect ON_GRID: the entity + # keeps its id and its history, and every template comparing it simply + # stops being true. + self.grid_state = text(mid, NODE_GRID, PROP_ISLANDING_STATE) or None # Retired in v1.0 with no drop-in successor, and deliberately left # None rather than substituted: `dominant-power-source` split into diff --git a/tests/test_schema_one_panel.py b/tests/test_schema_one_panel.py index 9072de9..832a2b7 100644 --- a/tests/test_schema_one_panel.py +++ b/tests/test_schema_one_panel.py @@ -13,6 +13,7 @@ from ebus_sdk.homie import DiscoveredDevice +from span_panel_api_schema_1.const import NODE_GRID from span_panel_api_schema_1.panel import ( PanelFields, build_unmapped_tabs, @@ -24,6 +25,7 @@ _TREE = json.loads((Path(__file__).parent / "fixtures" / "parent_child_tree.json").read_text(encoding="utf-8")) PANEL = "example-40t-001" +MID = "bess-mid" def _device(device_id: str) -> DiscoveredDevice: @@ -145,8 +147,32 @@ def test_missing_lugs_yield_zeros_not_errors() -> None: def test_grid_state_comes_from_the_mid(fields: PanelFields) -> None: - """It moved off the panel to the device where islanding is decided.""" - assert fields.grid_state == "UP" + """It moved off the panel to the device where islanding is decided. + + And it comes from `islanding-state`, keeping the flat schema's + ON_GRID/OFF_GRID vocabulary. + """ + assert fields.grid_state == "ON_GRID" + + +def test_grid_state_is_not_the_mids_utility_health_signal() -> None: + """The MID publishes two grid properties and only one of them is this. + + `grid/grid-state` answers whether the utility supply is UP, DOWN or + DEGRADED — new in v1.0, with no flat equivalent. `grid/islanding-state` + answers ON_GRID/OFF_GRID, which is what the flat schema's `grid_state` + meant and what every existing template compares against. Taking the + similarly-named one keeps the entity's id and history while silently + changing its vocabulary, so this pins the distinction rather than trusting + it. + """ + mid = _device(MID) + assert mid.get_property(NODE_GRID, "grid-state") == "UP" + assert mid.get_property(NODE_GRID, "islanding-state") == "ON_GRID" + + fields = PanelFields(panel=_device(PANEL), upstream_lugs=None, downstream_lugs=None, mid=mid) + + assert fields.grid_state == "ON_GRID" def test_retired_fields_are_none_rather_than_substituted(fields: PanelFields) -> None: diff --git a/tests/test_schema_one_snapshot.py b/tests/test_schema_one_snapshot.py index 229d2be..9501c50 100644 --- a/tests/test_schema_one_snapshot.py +++ b/tests/test_schema_one_snapshot.py @@ -99,7 +99,7 @@ def test_der_snapshots_are_populated(snapshot: SpanPanelSnapshot) -> None: def test_panel_and_lugs_values_reach_the_snapshot(snapshot: SpanPanelSnapshot) -> None: assert snapshot.instant_grid_power_w == -5847.0 assert snapshot.power_flow_pv == 8500.0 - assert snapshot.grid_state == "UP" + assert snapshot.grid_state == "ON_GRID" assert snapshot.l1_voltage == 120.0 From 7102f9260fea8f92d3d13bf35d887a0345102ba5 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:22:23 -0700 Subject: [PATCH 033/115] docs(schema_1): record the grid_state fix in the 0.1.0b1 changelog --- packages/schema-1/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index 1189d9d..4a376a1 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -40,3 +40,6 @@ Both found by verifying reconnect against a live broker, and both presented as a cannot leak. - **Readiness asked only about the root**, so a connection completed with a fraction of its circuits and no panel size. It now waits for every declared device to describe itself, at any depth. Child _state_ is deliberately not required, so an offline DER does not block a connection; the model is required only when the root's description declares it. +- **`grid_state` read the wrong one of the MID's two grid properties.** The MID publishes both `grid/islanding-state` (`ON_GRID`/`OFF_GRID`/`UNKNOWN`) and `grid/grid-state` (`UP`/`DOWN`/`DEGRADED`/`UNKNOWN`). The flat schema's `grid_state` was the BESS's + `grid-state`, an islanding answer, so its successor is `islanding-state`; `grid/grid-state` asks whether the utility supply is healthy and is new in v1.0 with no flat equivalent. Matching on the property name rather than the value set put `UP` where a + consumer expects `ON_GRID` — an entity keeping its id and history while its vocabulary silently changed. `grid/grid-state` is left unmapped, being a new signal rather than a replacement for an existing field. From 991ca6c206d603d942a574405a192551df260da3 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:39:37 -0700 Subject: [PATCH 034/115] ci: make CI and Release dispatchable Both workflows were reachable only by webhook. When delivery is dropped -- a platform incident throttling webhooks is enough -- a push lands with no CI run and a published release never publishes, and in both cases the run list looks identical to the healthy case. Nothing surfaces the gap. workflow_dispatch adds an API path to the same workflows, so either can be driven by hand against a specific ref. Release must be dispatched against a tag: it derives the distribution and version from the tag name, and a branch ref carries neither, so it exits on the existing error case instead. --- .github/workflows/ci.yml | 6 ++++++ .github/workflows/release.yml | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8d6c9c..f154c4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,12 @@ on: branches: [ main, develop ] pull_request: branches: [ main, develop ] + # Push and pull_request both arrive by webhook, so a dropped delivery leaves a + # commit with no run at all -- which reads the same as a commit that passed. + # Dispatch re-runs this against any ref on demand. + # + # gh workflow run ci.yml --ref develop + workflow_dispatch: jobs: lint-and-test: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d7dcd0..3d9694a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,18 @@ name: Release on: release: types: [published] + # A `release: published` event reaches this workflow only through webhook + # delivery, and a dropped delivery is silent: the release exists, the tag + # exists, nothing publishes, and the run list looks the same as it did before. + # Dispatch goes through the API instead, so a release can always be driven to + # PyPI by hand. + # + # gh workflow run release.yml --ref schema-1-v0.1.0b1 + # + # Dispatch a tag, never a branch. The steps below read the distribution and + # version out of the tag name, so a branch ref carries neither; it falls + # through to the error case rather than being guessed at. + workflow_dispatch: # This repo publishes two distributions that version independently: the # bootstrap (span-panel-api) and each schema adapter (span-panel-api-schema-N). From 6674ff7f36b5037460262b0cda0265c738705958 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:13:14 -0700 Subject: [PATCH 035/115] feat(adapters): reject adapters built against a different contract Member presence was never the whole contract. A Protocol cannot express signatures at runtime, so an adapter carrying every required name and the previous __init__ arity passed discovery and failed later inside the transport as a bare TypeError about an argument count -- the least actionable moment to learn that two installed packages were built against different versions of each other. adapters.py said as much in a comment; this closes it. SchemaAdapter now requires a declared ADAPTER_CONTRACT integer, checked at discovery against the bootstrap's ADAPTER_CONTRACT_VERSION. Adapters declare it as a literal: a value imported from the installed bootstrap would agree with every bootstrap, which is precisely the disagreement being looked for. Discovery keeps rejections alongside adapters rather than only logging them. Absent and rejected are the same absence from the registry but opposite remedies -- install something, versus upgrade what is already installed -- and reporting the second as the first sends someone to install a package they already have. resolve_adapter raises the new SpanPanelAdapterIncompatibleError when the key a panel needs was rejected, and the existing missing error when nothing claims it. Discovery itself still only logs, so one unusable third-party adapter cannot take down a panel whose own adapter is fine. Verified against the artifact that motivated it: the published span-panel-api-schema-0 1.0.0b1, whose adapter takes (serial_number, panel_size), is now refused at discovery naming the remedy instead of reaching construction. --- .../src/span_panel_api_schema_0/adapter.py | 5 + .../src/span_panel_api_schema_1/adapter.py | 5 + src/span_panel_api/adapters.py | 141 ++++++++++++++---- src/span_panel_api/exceptions.py | 26 ++++ src/span_panel_api/protocol.py | 25 ++++ tests/test_adapters_discovery.py | 140 ++++++++++++++++- 6 files changed, 301 insertions(+), 41 deletions(-) diff --git a/packages/schema-0/src/span_panel_api_schema_0/adapter.py b/packages/schema-0/src/span_panel_api_schema_0/adapter.py index ce279bf..f44ea14 100644 --- a/packages/schema-0/src/span_panel_api_schema_0/adapter.py +++ b/packages/schema-0/src/span_panel_api_schema_0/adapter.py @@ -22,6 +22,11 @@ class SchemaZeroAdapter: """Parser for the flat single-device schema (firmware r202603-r202627).""" + # A literal, deliberately not imported from span_panel_api.protocol: a value + # read from the installed bootstrap would agree with every bootstrap, which + # is the disagreement the check exists to find. Bump when this adapter is + # rebuilt against a new contract, never to match what happens to be installed. + ADAPTER_CONTRACT: int = 1 schema_major = "schema_0" SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = (">=0", "<1.0") diff --git a/packages/schema-1/src/span_panel_api_schema_1/adapter.py b/packages/schema-1/src/span_panel_api_schema_1/adapter.py index e8aaff9..9232df4 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/adapter.py +++ b/packages/schema-1/src/span_panel_api_schema_1/adapter.py @@ -51,6 +51,11 @@ class SchemaOneAdapter: """Parser for the parent/child schema (data-model-version 1.x).""" + # A literal, deliberately not imported from span_panel_api.protocol: a value + # read from the installed bootstrap would agree with every bootstrap, which + # is the disagreement the check exists to find. Bump when this adapter is + # rebuilt against a new contract, never to match what happens to be installed. + ADAPTER_CONTRACT: int = 1 schema_major = "schema_1" SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = (">=1.0", "<2.0") diff --git a/src/span_panel_api/adapters.py b/src/span_panel_api/adapters.py index 2ddfe71..5cf0be1 100644 --- a/src/span_panel_api/adapters.py +++ b/src/span_panel_api/adapters.py @@ -6,16 +6,34 @@ from __future__ import annotations +from dataclasses import dataclass from importlib.metadata import entry_points import logging from typing import TypeGuard -from span_panel_api.exceptions import SpanPanelAdapterMissingError -from span_panel_api.protocol import SchemaAdapter +from span_panel_api.exceptions import SpanPanelAdapterIncompatibleError, SpanPanelAdapterMissingError +from span_panel_api.protocol import ADAPTER_CONTRACT_VERSION, SchemaAdapter _LOGGER = logging.getLogger(__name__) _ENTRY_POINT_GROUP = "span_panel_api.schema_adapters" -_REGISTRY: dict[str, type[SchemaAdapter]] | None = None + + +@dataclass(frozen=True) +class _Discovery: + """One scan of the entry-point group: what was usable, and why the rest was not. + + Rejections are kept rather than only logged. A rejected adapter and an + absent one are the same absence from ``adapters``, but they are opposite + problems for whoever hits them — install something, versus upgrade what is + already installed. Keeping the reason is what lets ``resolve_adapter`` tell + them apart at the point the distinction matters, without re-scanning. + """ + + adapters: dict[str, type[SchemaAdapter]] + rejected: dict[str, str] + + +_DISCOVERY: _Discovery | None = None def _derive_required_members(protocol: type) -> tuple[str, ...]: @@ -62,12 +80,16 @@ def _is_adapter_class(loaded: object) -> TypeGuard[type[SchemaAdapter]]: the boundary where that `Any` has to become a checked `type[SchemaAdapter]` rather than being assigned into the registry unexamined. - Deliberately checks member *presence* only. A Protocol cannot express - signatures at runtime, so an adapter with the right names and the wrong - arity still gets through and fails at call time. The check is worth having - anyway: it catches the failure that actually happens — a module, function or - instance registered where a class belongs — and turns it into a named, - logged skip instead of an opaque TypeError deep inside connect(). + Checks member *presence* only, which is all a Protocol can express at + runtime: an adapter carrying every required name and the wrong ``__init__`` + arity still satisfies this. That gap is why the protocol also requires a + declared ``ADAPTER_CONTRACT`` and why ``_contract_defect`` runs after this — + presence answers "is this an adapter", the contract answers "is it one this + package can drive". + + Worth having on its own regardless: it catches a module, function or + instance registered where a class belongs, and turns it into a named, logged + skip instead of an opaque TypeError deep inside connect(). """ return isinstance(loaded, type) and all(hasattr(loaded, member) for member in _REQUIRED_MEMBERS) @@ -77,50 +99,103 @@ def _describe_defect(loaded: object) -> str: if not isinstance(loaded, type): return f"expected a class, got {type(loaded).__name__}" missing = [member for member in _REQUIRED_MEMBERS if not hasattr(loaded, member)] - return f"{loaded.__name__} does not implement SchemaAdapter (missing: {', '.join(missing)})" - - -def discover_adapters() -> dict[str, type[SchemaAdapter]]: - """Load and cache every adapter class registered under the entry-point group. + if "ADAPTER_CONTRACT" in missing: + # Every adapter built for a contract-versioned bootstrap declares this, + # so its absence dates the package rather than faulting it: this is an + # adapter from before the contract was versioned at all. + return ( + f"{loaded.__name__} declares no ADAPTER_CONTRACT, so it predates contract " + f"versioning and was built against an older span-panel-api. Install an adapter " + f"release built for contract {ADAPTER_CONTRACT_VERSION}." + ) + return f"{loaded.__name__} does not implement SchemaAdapter (missing: {', '.join(missing)})." + + +def _contract_defect(adapter_cls: type[SchemaAdapter]) -> str | None: + """Reject an adapter built against a different contract. None when usable. + + Runs only after `_is_adapter_class`, so the attribute is known to exist and + the remaining questions are whether it is an integer and whether it agrees. + """ + declared: object = adapter_cls.ADAPTER_CONTRACT + # bool is a subclass of int, and `ADAPTER_CONTRACT = True` comparing equal + # to contract 1 would be an absurd way to pass this check. + if not isinstance(declared, int) or isinstance(declared, bool): + return f"{adapter_cls.__name__} declares ADAPTER_CONTRACT={declared!r}, which is not an integer." + if declared != ADAPTER_CONTRACT_VERSION: + direction = "older than" if declared < ADAPTER_CONTRACT_VERSION else "newer than" + return ( + f"{adapter_cls.__name__} is built for adapter contract {declared}, " + f"{direction} the contract {ADAPTER_CONTRACT_VERSION} this span-panel-api speaks." + ) + return None + + +def _discover() -> _Discovery: + """Scan and cache the entry-point group, keeping rejections alongside adapters. A bad entry point is skipped with a logged reason, never raised: one broken third-party adapter must not take down a panel whose own adapter is fine. + Whether a skip matters is decided later, by whoever asks for that key. """ - global _REGISTRY # pylint: disable=global-statement # process-lifetime cache by design - if _REGISTRY is None: - registry: dict[str, type[SchemaAdapter]] = {} + global _DISCOVERY # pylint: disable=global-statement # process-lifetime cache by design + if _DISCOVERY is None: + adapters: dict[str, type[SchemaAdapter]] = {} + rejected: dict[str, str] = {} for ep in entry_points(group=_ENTRY_POINT_GROUP): - if ep.name in registry: + if ep.name in adapters or ep.name in rejected: _LOGGER.warning("Duplicate schema adapter entry point %r; keeping the first found", ep.name) continue try: loaded: object = ep.load() except Exception: # pylint: disable=broad-exception-caught _LOGGER.exception("Failed to load schema adapter entry point %r", ep.name) + rejected[ep.name] = "the package raised on import; see the logged traceback." continue if not _is_adapter_class(loaded): - _LOGGER.error("Ignoring schema adapter entry point %r: %s", ep.name, _describe_defect(loaded)) + shape_defect = _describe_defect(loaded) + _LOGGER.error("Ignoring schema adapter entry point %r: %s", ep.name, shape_defect) + rejected[ep.name] = shape_defect + continue + if (contract_defect := _contract_defect(loaded)) is not None: + _LOGGER.error("Ignoring schema adapter entry point %r: %s", ep.name, contract_defect) + rejected[ep.name] = contract_defect continue - registry[ep.name] = loaded - _REGISTRY = registry - return _REGISTRY + adapters[ep.name] = loaded + _DISCOVERY = _Discovery(adapters=adapters, rejected=rejected) + return _DISCOVERY + + +def discover_adapters() -> dict[str, type[SchemaAdapter]]: + """Every adapter class this package can actually drive, by entry-point name. + + Rejected entry points are deliberately absent rather than present-but-broken: + a caller iterating this should never have to re-check what discovery already + decided. + """ + return _discover().adapters def resolve_adapter(key: str, reason: str) -> type[SchemaAdapter]: - """Return the discovered adapter class for `key`, or raise naming what is installed. + """Return the discovered adapter class for `key`, or raise saying why not. + + The one place an unavailable adapter turns into a named error. Both the + factory's Tier 1 dispatch and the transport's default path go through here so + a user whose panel outruns their install sees the same message either way. - The one place a missing adapter turns into a named error. Both the factory's - Tier 1 dispatch and the transport's default path go through here so a user - whose panel outruns their install sees the same message either way. + Absent and rejected are separated here rather than at discovery, because + only here is it known that this particular key is the one the panel needs. """ - registry = discover_adapters() - adapter_cls = registry.get(key) - if adapter_cls is None: - raise SpanPanelAdapterMissingError(needed=key, reason=reason, available=sorted(registry)) - return adapter_cls + discovery = _discover() + adapter_cls = discovery.adapters.get(key) + if adapter_cls is not None: + return adapter_cls + if (defect := discovery.rejected.get(key)) is not None: + raise SpanPanelAdapterIncompatibleError(needed=key, reason=reason, defect=defect) + raise SpanPanelAdapterMissingError(needed=key, reason=reason, available=sorted(discovery.adapters)) def _reset_adapter_cache() -> None: """Test hook. Not public API.""" - global _REGISTRY # pylint: disable=global-statement # test hook for the cache above - _REGISTRY = None + global _DISCOVERY # pylint: disable=global-statement # test hook for the cache above + _DISCOVERY = None diff --git a/src/span_panel_api/exceptions.py b/src/span_panel_api/exceptions.py index 765966c..75b5f8c 100644 --- a/src/span_panel_api/exceptions.py +++ b/src/span_panel_api/exceptions.py @@ -77,3 +77,29 @@ def __init__(self, needed: str, reason: str, available: list[str]) -> None: f"installed adapters: {sorted(available)}. " "Update the integration or install the missing adapter package." ) + + +class SpanPanelAdapterIncompatibleError(SpanPanelError): + """An adapter for this schema is installed, but this package cannot use it. + + Distinct from SpanPanelAdapterMissingError because the remedy is the + opposite one. "Missing" means nothing claims this schema, and the answer is + to install something. This means a package *does* claim it and was rejected, + so installing more cannot help — the two installed pieces were built against + different versions of the same contract, and one of them has to move. + + Raised rather than logged because the panel needing this adapter has no + other parser. Discovery still only logs, so one unusable third-party adapter + does not take down a panel whose own adapter is fine; this fires only when + the rejected adapter turns out to be the one actually required. + """ + + def __init__(self, needed: str, reason: str, defect: str) -> None: + self.needed = needed + self.reason = reason + self.defect = defect + super().__init__( + f"Panel requires adapter {needed!r} (reason: {reason}), and an installed " + f"package registers it, but it cannot be used: {defect} " + "Upgrade span-panel-api and the adapter package together." + ) diff --git a/src/span_panel_api/protocol.py b/src/span_panel_api/protocol.py index 4fa2b35..4a4bae7 100644 --- a/src/span_panel_api/protocol.py +++ b/src/span_panel_api/protocol.py @@ -79,6 +79,30 @@ async def start_streaming(self) -> None: ... async def stop_streaming(self) -> None: ... +ADAPTER_CONTRACT_VERSION = 1 +"""The bootstrap-to-adapter contract this package speaks. + +Bumped only when a change leaves existing adapters unusable — a different +``__init__`` signature, or a method whose meaning changes under an unchanged +name. Purely additive changes do not bump it: ``_derive_required_members`` +already requires every member the protocol declares, so an adapter missing a +newly added method is rejected on that basis alone. + +This exists because member presence is not the whole contract. A Protocol +cannot express signatures at runtime, so an adapter carrying every required +name and the wrong ``__init__`` arity passes discovery and fails much later, +inside the transport, as a bare ``TypeError`` about an argument count. That is +exactly what a stale adapter looks like, and it is the least actionable moment +to find out. A declared integer is checkable at discovery, where the remedy — +upgrade this package — can still be named. + +**Adapters must declare this as a literal, never by importing this constant.** +An adapter that echoes whatever the installed bootstrap defines agrees with +every bootstrap by construction, which is precisely the disagreement being +looked for. The value has to be baked into the adapter's wheel at build time. +""" + + @runtime_checkable class SchemaAdapter(Protocol): """Parser for a single data-model-major schema. @@ -90,6 +114,7 @@ class SchemaAdapter(Protocol): consumers of the active adapter. """ + ADAPTER_CONTRACT: int schema_major: str SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] diff --git a/tests/test_adapters_discovery.py b/tests/test_adapters_discovery.py index 41b3fed..71e1a06 100644 --- a/tests/test_adapters_discovery.py +++ b/tests/test_adapters_discovery.py @@ -1,14 +1,21 @@ from __future__ import annotations -from typing import Protocol +from typing import Any, Protocol from unittest.mock import patch import pytest -from span_panel_api.adapters import DEFAULT_ADAPTER_KEY, _reset_adapter_cache, discover_adapters, resolve_adapter -from span_panel_api.exceptions import SpanPanelAdapterMissingError +from span_panel_api.adapters import ( + DEFAULT_ADAPTER_KEY, + _Discovery, + _reset_adapter_cache, + discover_adapters, + resolve_adapter, +) +from span_panel_api.exceptions import SpanPanelAdapterIncompatibleError, SpanPanelAdapterMissingError from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.models import MqttClientConfig +from span_panel_api.protocol import ADAPTER_CONTRACT_VERSION from conftest import MOCK_SCHEMA @@ -37,6 +44,15 @@ def _client(adapter_factory: object = None) -> SpanMqttClient: return SpanMqttClient("panel.local", "SERIAL123", config, **kwargs) # type: ignore[arg-type] +def _nothing_installed() -> Any: + """Patch discovery to a completed scan that found nothing. + + A completed empty scan, not a missing one: `None` would make the next call + re-scan and pick up this environment's real adapters. + """ + return patch("span_panel_api.adapters._DISCOVERY", _Discovery(adapters={}, rejected={})) + + def test_default_factory_resolves_the_flat_adapter_through_discovery() -> None: """No adapter_factory means "resolve the default key", not "import SchemaZeroAdapter".""" _reset_adapter_cache() @@ -53,7 +69,7 @@ def test_constructing_a_client_does_not_require_an_installed_adapter() -> None: This is the property that lets the bootstrap ship without a parser at all. """ - with patch("span_panel_api.adapters._REGISTRY", {}): + with _nothing_installed(): _client() # must not raise @@ -62,7 +78,7 @@ def test_building_a_parser_without_any_adapter_raises_by_name() -> None: _reset_adapter_cache() client = _client() - with patch("span_panel_api.adapters._REGISTRY", {}), pytest.raises(SpanPanelAdapterMissingError) as exc: + with _nothing_installed(), pytest.raises(SpanPanelAdapterMissingError) as exc: client._build_adapter(MOCK_SCHEMA) assert exc.value.needed == DEFAULT_ADAPTER_KEY @@ -111,6 +127,23 @@ def _discover_with(*eps: _FakeEntryPoint) -> dict[str, object]: return dict(discover_adapters()) +def _conforming_members(contract: object = ADAPTER_CONTRACT_VERSION) -> dict[str, object]: + """Members for a class that passes discovery, derived from the protocol. + + Derived rather than listed so it stays honest as SchemaAdapter grows: a test + that builds its fixture by hand starts passing for the wrong reason the day + a member is added. + + ADAPTER_CONTRACT is the one member a callable will not do for, because it is + checked for value and not only presence — which is the whole point of it. + """ + from span_panel_api.adapters import _REQUIRED_MEMBERS + + members: dict[str, object] = {name: (lambda self, *args, **kwargs: None) for name in _REQUIRED_MEMBERS} + members["ADAPTER_CONTRACT"] = contract + return members + + def test_required_members_are_derived_from_the_protocol() -> None: """The check must not restate the contract — a method added to SchemaAdapter becomes required of every adapter without anyone remembering to update a list.""" @@ -162,9 +195,7 @@ def test_an_adapter_missing_a_non_method_member_is_still_rejected() -> None: what makes the 'incomplete' half's rejection attributable to the one removed member rather than to an unrelated gap. """ - from span_panel_api.adapters import _REQUIRED_MEMBERS - - complete = {name: (lambda self, *args, **kwargs: None) for name in _REQUIRED_MEMBERS} + complete = _conforming_members() incomplete = {name: value for name, value in complete.items() if name != "SUPPORTS_DATA_MODEL_VERSIONS"} assert _discover_with(_FakeEntryPoint("schema_9", type("Complete", (), complete))) != {} @@ -224,3 +255,96 @@ def load(self) -> object: registry = _discover_with(Exploding("schema_9", None), _FakeEntryPoint("schema_0", SchemaZeroAdapter)) assert registry == {"schema_0": SchemaZeroAdapter} + + +# --------------------------------------------------------------------------- +# Contract versioning — an adapter built against a different bootstrap must be +# rejected where the remedy can still be named, not at construction +# --------------------------------------------------------------------------- + + +def test_the_shipped_adapters_declare_the_contract_this_package_speaks() -> None: + """The pairing that actually ships. Both adapters version independently of + the bootstrap, so nothing but this check keeps their declared contract + honest when the protocol moves.""" + from span_panel_api_schema_0 import SchemaZeroAdapter + from span_panel_api_schema_1 import SchemaOneAdapter + + assert SchemaZeroAdapter.ADAPTER_CONTRACT == ADAPTER_CONTRACT_VERSION + assert SchemaOneAdapter.ADAPTER_CONTRACT == ADAPTER_CONTRACT_VERSION + + +@pytest.mark.parametrize( + ("label", "contract"), + [ + ("older", ADAPTER_CONTRACT_VERSION - 1), + ("newer", ADAPTER_CONTRACT_VERSION + 1), + ], +) +def test_an_adapter_built_for_another_contract_is_rejected(label: str, contract: int) -> None: + """Both directions, because either half can be the stale one: an old adapter + against a new bootstrap, or an adapter from a future release against this.""" + members = _conforming_members(contract=contract) + + assert _discover_with(_FakeEntryPoint("schema_9", type("Mismatched", (), members))) == {}, label + + +def test_a_contract_that_is_not_an_integer_is_rejected() -> None: + """`True == 1` is the trap: bool is a subclass of int, so a truthy marker + would otherwise compare equal to contract 1 and be accepted.""" + assert _discover_with(_FakeEntryPoint("schema_9", type("Truthy", (), _conforming_members(contract=True)))) == {} + assert _discover_with(_FakeEntryPoint("schema_9", type("Stringly", (), _conforming_members(contract="1")))) == {} + + +def test_an_adapter_predating_contract_versioning_is_rejected_by_age_not_by_shape() -> None: + """The real regression this closes: schema-1 0.1.0b1 paired with a bootstrap + whose adapters took `panel_size`. Such an adapter carries every other + required name, so nothing but the contract member distinguishes it, and + without one it reached construction and died on argument count.""" + members = _conforming_members() + del members["ADAPTER_CONTRACT"] + + _reset_adapter_cache() + with patch( + "span_panel_api.adapters.entry_points", + return_value=[_FakeEntryPoint("schema_9", type("Ancient", (), members))], + ): + with pytest.raises(SpanPanelAdapterIncompatibleError) as exc: + resolve_adapter("schema_9", "test") + + assert "predates contract versioning" in str(exc.value) + + +def test_a_rejected_adapter_is_reported_as_unusable_not_as_missing() -> None: + """Absent and rejected are opposite remedies. Reporting a stale adapter as + missing sends someone to install a package they already have.""" + members = _conforming_members(contract=ADAPTER_CONTRACT_VERSION + 1) + + _reset_adapter_cache() + with patch( + "span_panel_api.adapters.entry_points", + return_value=[_FakeEntryPoint("schema_9", type("FromTheFuture", (), members))], + ): + with pytest.raises(SpanPanelAdapterIncompatibleError) as exc: + resolve_adapter("schema_9", "panel needs it") + + assert exc.value.needed == "schema_9" + assert "contract" in exc.value.defect + # Still the missing error when nothing registers the key at all, so the two + # paths cannot quietly collapse into one message. + assert not isinstance(exc.value, SpanPanelAdapterMissingError) + + +def test_a_rejected_adapter_does_not_make_a_working_one_unreachable() -> None: + """The rejection is per entry point. A stale third-party adapter must not + stop the panel whose own adapter is fine, which is why discovery logs rather + than raises and only resolve_adapter turns it into an error.""" + from span_panel_api_schema_0 import SchemaZeroAdapter + + stale = type("Stale", (), _conforming_members(contract=ADAPTER_CONTRACT_VERSION + 1)) + registry = _discover_with( + _FakeEntryPoint("schema_9", stale), + _FakeEntryPoint("schema_0", SchemaZeroAdapter), + ) + + assert registry == {"schema_0": SchemaZeroAdapter} From 3dd8cd256aabedb04f7f350efdcfd57393f6de6c Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:13:42 -0700 Subject: [PATCH 036/115] chore(release): span-panel-api 3.0.0b2, schema-0 1.0.0b2, schema-1 0.1.0b2 One batch, because the three cannot be released independently: the adapter construction contract changed, so a bootstrap from one side of that change and an adapter from the other cannot work together. schema-1 0.1.0b1 is the reason. It shipped built against the reshaped protocol while declaring span-panel-api>=3.0.0b1, and no published bootstrap satisfied that in practice -- 3.0.0b1's V2HomieSchema has no data_model_version field, so a 1.x panel could not be represented at all, and its factory hardcoded the version to None, so the adapter was discoverable and never selectable. Both adapter floors are now >=3.0.0b2, the first release where the contract holds. Nothing was installed against the old floor. The combination was unreachable rather than broken in the field, so the affected release is left in place rather than yanked. --- CHANGELOG.md | 13 ++++++++++++- packages/schema-0/CHANGELOG.md | 13 ++++++++++--- packages/schema-0/pyproject.toml | 4 ++-- packages/schema-1/CHANGELOG.md | 15 +++++++++++++++ packages/schema-1/pyproject.toml | 4 ++-- pyproject.toml | 2 +- uv.lock | 6 +++--- 7 files changed, 45 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 083b019..2248897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [3.0.0b2] - 08/2026 + +Pre-release. Releases the reshaped `SchemaAdapter` protocol that `3.0.0b1` predates, and makes the mismatch between the two detectable rather than fatal at construction. + +### Added + +- **Adapter contract versioning.** `SchemaAdapter` now requires an `ADAPTER_CONTRACT` integer, and discovery rejects any adapter that does not declare this package's `ADAPTER_CONTRACT_VERSION`. Member presence was never the whole contract: a Protocol + cannot express signatures at runtime, so an adapter carrying every required name and the previous `__init__` arity passed discovery and failed much later inside the transport, as a bare `TypeError` about an argument count — the least actionable moment to + learn that two installed packages were built against different versions of each other. Adapters must declare the value as a **literal**; one read from the installed bootstrap would agree with every bootstrap, which is the disagreement being looked for. +- **`SpanPanelAdapterIncompatibleError`**, raised when the adapter a panel needs is installed but unusable. Distinct from `SpanPanelAdapterMissingError` because the remedy inverts: missing means install something, incompatible means installing more cannot + help. Reporting the second as the first sends someone to install a package they already have. Discovery still only _logs_ a rejection, so one unusable third-party adapter cannot take down a panel whose own adapter is fine; the error surfaces only when + the rejected adapter turns out to be the one required. ### Fixed diff --git a/packages/schema-0/CHANGELOG.md b/packages/schema-0/CHANGELOG.md index 08f2ff5..3586a85 100644 --- a/packages/schema-0/CHANGELOG.md +++ b/packages/schema-0/CHANGELOG.md @@ -7,15 +7,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is fixed — the flat single-device schema, SPAN firmware `r202603` through `r202627` — and is identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version number. A release here means this parser changed, never that the panel did. -## [Unreleased] +## [1.0.0b2] - 08/2026 + +Pre-release. Follows the reshaped `SchemaAdapter` protocol released in `span-panel-api` 3.0.0b2. + +### Added + +- **`ADAPTER_CONTRACT = 1`**, declaring which version of the bootstrap-to-adapter contract this parser was built against. Declared as a literal rather than imported from `span_panel_api.protocol`: a value read from the installed bootstrap would agree with + every bootstrap, which is exactly the disagreement the check exists to find. ### Changed - **BREAKING: `SchemaZeroAdapter(serial_number, schema)`** replaces `SchemaZeroAdapter(serial_number, panel_size)`, following the protocol change in `span-panel-api`. Panel size is now derived here, by reading the circuit `space` format out of the flat schema's `types` block — knowledge that belongs to this package rather than to the transport, which was previously doing it on every adapter's behalf. - **`build_field_metadata()` takes no arguments**, reading the schema this adapter was constructed with. - -Requires `span-panel-api` with the reshaped `SchemaAdapter` protocol; the dependency floor is raised accordingly at release. +- **The `span-panel-api` floor is now `>=3.0.0b2`.** `1.0.0b1` declared `>=3.0.0b1`, which admitted a bootstrap that constructs adapters with `panel_size` — a pairing that could not work. Installing that combination now fails by name at discovery rather + than on argument count inside the transport, but the floor is what stops a resolver reaching it at all. ## [1.0.0b1] - 08/2026 diff --git a/packages/schema-0/pyproject.toml b/packages/schema-0/pyproject.toml index b7d6a58..c6e44e6 100644 --- a/packages/schema-0/pyproject.toml +++ b/packages/schema-0/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api-schema-0" -version = "1.0.0b1" +version = "1.0.0b2" description = "Flat-schema (data-model-version absent) parser for span-panel-api" authors = [ {name = "SpanPanel"} @@ -9,7 +9,7 @@ readme = "README.md" license = "MIT" requires-python = ">=3.10,<4.0" dependencies = [ - "span-panel-api>=3.0.0b1,<4.0", + "span-panel-api>=3.0.0b2,<4.0", ] [project.urls] diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index 4a376a1..7e4c6bc 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -7,6 +7,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is the parent/child device tree SPAN firmware `r202633+` publishes, identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version number. A release here means this parser changed, never that the panel did. +## [0.1.0b2] - 08/2026 + +Pre-release. Corrects the dependency floor `0.1.0b1` shipped with, and follows the reshaped `SchemaAdapter` protocol released in `span-panel-api` 3.0.0b2. + +### Added + +- **`ADAPTER_CONTRACT = 1`**, declaring which version of the bootstrap-to-adapter contract this parser was built against. Declared as a literal rather than imported from `span_panel_api.protocol`: a value read from the installed bootstrap would agree with + every bootstrap, which is exactly the disagreement the check exists to find. + +### Fixed + +- **The `span-panel-api` floor was `>=3.0.0b1`, which no published bootstrap could satisfy in practice.** `0.1.0b1` was built against a bootstrap that reads the panel's `data-model-version` and constructs adapters with the whole schema; the only bootstrap + on PyPI at the time did neither. Its `V2HomieSchema` had no `data_model_version` field at all, so a `1.x` panel could not even be represented, and its factory hardcoded the version to `None` — meaning this adapter was discoverable and never selectable. + The floor is now `>=3.0.0b2`, the first release where both hold. Nothing was installed against the old floor; the combination was unreachable rather than broken in the field. + ## [0.1.0b1] - 08/2026 Pre-release. First release as a standalone distribution, and the first parser for the parent/child data model. diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index 75c7054..239de4f 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api-schema-1" -version = "0.1.0b1" +version = "0.1.0b2" description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api" authors = [ {name = "SpanPanel"} @@ -9,7 +9,7 @@ readme = "README.md" license = "MIT" requires-python = ">=3.10,<4.0" dependencies = [ - "span-panel-api>=3.0.0b1,<4.0", + "span-panel-api>=3.0.0b2,<4.0", # Only this distribution depends on the eBus SDK. The bootstrap and # schema-0 stay clean, so a flat-panel install never pulls it in — which is # what bounds the release coupling this dependency introduces to panels on diff --git a/pyproject.toml b/pyproject.toml index d0e3bd1..a99b380 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "3.0.0b1" +version = "3.0.0b2" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} diff --git a/uv.lock b/uv.lock index c06fe71..3c6bd99 100644 --- a/uv.lock +++ b/uv.lock @@ -1323,7 +1323,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "3.0.0b1" +version = "3.0.0b2" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -1380,7 +1380,7 @@ dev = [ [[package]] name = "span-panel-api-schema-0" -version = "1.0.0b1" +version = "1.0.0b2" source = { editable = "packages/schema-0" } dependencies = [ { name = "span-panel-api" }, @@ -1391,7 +1391,7 @@ requires-dist = [{ name = "span-panel-api", editable = "." }] [[package]] name = "span-panel-api-schema-1" -version = "0.1.0b1" +version = "0.1.0b2" source = { editable = "packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From 0604de0ca17413ecc6258d619db66c2d81f66506 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:16:03 -0700 Subject: [PATCH 037/115] feat(schema_1): check every name this adapter reads against the eBus spec The flat adapter pins 64 hardcoded facts against SPAN's schema document. The parent/child adapter pinned nothing, despite tracking a specification that is alive and moving while the flat schema is frozen and being retired. That asymmetry ran the wrong way round. The check asks the consumer's question, which is not the publisher's mirror image. A publisher asks whether everything it emits is legal, and for it an omission is unremarkable -- the simulator records 1486 of them. This asks whether every name we read is one the specification defines, because a consumer addressing a name that no longer exists does not fail. The property never arrives, metadata lookup returns None, and an entity disappears. ebus-sdk 0.18.0 removing the `battery` capability key in favour of `soc` with no alias is exactly that shape. Fourteen of the forty-two properties read are absent from every catalog -- per-phase meter readings, panel link states, circuit spaces, the EVSE surface. All legal; the specification permits properties it has never heard of. They are enumerated with reasons so that a name missing from the catalog has to be a deliberate claim about SPAN's vocabulary rather than an unnoticed typo, which at runtime looks identical. The reverse directions are checked too: an extension later adopted upstream, and an extension left behind for a property nothing reads. The 13 catalogs are byte-copied and never parsed in production. Units and datatypes still come from each device's $description, because the catalog is the superset across all hardware rather than a statement about this panel -- and because four catalog properties carry `unit: energy`, a dimension rather than a unit. A test asserts field_metadata.py does not reach for the vendored copies, since that is the obvious shortcut the day a description omits something. Formatting hooks skip spec/. A lint fix there would silently invalidate the byte comparison that makes the copies worth having; markdownlint globs the tree itself, so it needed ignoring in its own config rather than pre-commit's. Byte comparison against a specification checkout runs only when EBUS_SPEC_DIR points at one, so conformance runs everywhere and provenance stays opportunistic. Provenance proves the right bytes were copied; it cannot prove they were understood. --- .markdownlint-cli2.jsonc | 6 + .pre-commit-config.yaml | 15 +- packages/schema-1/CHANGELOG.md | 20 ++ packages/schema-1/spec/catalogs/breaker.json | 52 ++++ .../schema-1/spec/catalogs/connection.json | 72 ++++++ packages/schema-1/spec/catalogs/door.json | 17 ++ packages/schema-1/spec/catalogs/grid.json | 38 +++ packages/schema-1/spec/catalogs/info.json | 52 ++++ .../schema-1/spec/catalogs/load-shed.json | 18 ++ packages/schema-1/spec/catalogs/meter.json | 201 +++++++++++++++ packages/schema-1/spec/catalogs/pcs.json | 111 ++++++++ .../schema-1/spec/catalogs/power-flows.json | 35 +++ packages/schema-1/spec/catalogs/shed.json | 24 ++ packages/schema-1/spec/catalogs/soc.json | 35 +++ packages/schema-1/spec/catalogs/status.json | 28 ++ packages/schema-1/spec/catalogs/switch.json | 29 +++ .../schema-1/spec/registries/device-types.md | 56 ++++ .../span_panel_api_schema_1/spec_lock.json | 40 +++ tests/test_schema_one_conformance.py | 244 ++++++++++++++++++ 19 files changed, 1088 insertions(+), 5 deletions(-) create mode 100644 packages/schema-1/spec/catalogs/breaker.json create mode 100644 packages/schema-1/spec/catalogs/connection.json create mode 100644 packages/schema-1/spec/catalogs/door.json create mode 100644 packages/schema-1/spec/catalogs/grid.json create mode 100644 packages/schema-1/spec/catalogs/info.json create mode 100644 packages/schema-1/spec/catalogs/load-shed.json create mode 100644 packages/schema-1/spec/catalogs/meter.json create mode 100644 packages/schema-1/spec/catalogs/pcs.json create mode 100644 packages/schema-1/spec/catalogs/power-flows.json create mode 100644 packages/schema-1/spec/catalogs/shed.json create mode 100644 packages/schema-1/spec/catalogs/soc.json create mode 100644 packages/schema-1/spec/catalogs/status.json create mode 100644 packages/schema-1/spec/catalogs/switch.json create mode 100644 packages/schema-1/spec/registries/device-types.md create mode 100644 packages/schema-1/src/span_panel_api_schema_1/spec_lock.json create mode 100644 tests/test_schema_one_conformance.py diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index c759e95..16509c1 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -36,6 +36,12 @@ }, "globs": ["**/*.md"], "ignores": [ + // Byte copies of the eBus specification, verified by byte comparison in + // tests/test_schema_one_conformance.py. Upstream's line lengths are not + // ours to correct, and a fix here would invalidate that comparison. + // `globs` above scans the tree directly, so pre-commit's `exclude` cannot + // filter this out -- it has to be ignored here. + "packages/schema-1/spec/**", ".venv/**", "venv/**", "node_modules/**", diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9f86906..4e8c6a0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,10 +3,15 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: + # `packages/schema-1/spec/` holds byte copies of the eBus specification, + # verified by byte comparison in tests/test_schema_one_conformance.py. Any + # hook that rewrites a file must skip it: a "fix" there would silently + # invalidate the comparison that makes the copies trustworthy. Non-mutating + # checks (check-json) deliberately still run, since a corrupt copy should fail. - id: trailing-whitespace - exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' + exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' - id: end-of-file-fixer - exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' + exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' - id: check-yaml exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' - id: check-toml @@ -20,7 +25,7 @@ repos: exclude: '^src/span_panel_api/generated_client/.*|generate_client\.py|scripts/.*|tests/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|^examples/.*' - id: mixed-line-ending args: ['--fix=lf'] - exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' + exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' # Ruff for formatting and linting - repo: https://github.com/astral-sh/ruff-pre-commit @@ -55,7 +60,7 @@ repos: - id: prettier types: [markdown] args: ['--config', '.prettierrc.json'] - exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|node_modules/.*|htmlcov/.*' + exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|node_modules/.*|htmlcov/.*' # Markdownlint for markdown files (after Prettier formatting) - repo: https://github.com/DavidAnson/markdownlint-cli2 @@ -63,7 +68,7 @@ repos: hooks: - id: markdownlint-cli2 args: ['--config', '.markdownlint-cli2.jsonc'] - exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|node_modules/.*|htmlcov/.*' + exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|node_modules/.*|htmlcov/.*' # MyPy for type checking - repo: https://github.com/pre-commit/mirrors-mypy diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index 7e4c6bc..ecc1ffb 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -7,6 +7,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is the parent/child device tree SPAN firmware `r202633+` publishes, identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version number. A release here means this parser changed, never that the panel did. +## [Unreleased] + +### Added + +- **Spec conformance checking.** `spec_lock.json` ships with the package and records what this parser targets: the firmware range, the eBus specification commit its vocabulary was read from, and the version of every capability, device and registry it + implements. It is the consumer counterpart to the simulator's publisher lockfile, and both are pinned to the same specification commit — though the anchor shared between them is the **firmware range**, not that commit, because the specification says what + a device class _may_ publish while a panel publishes one specific tree. +- **The 13 capability catalogs this adapter addresses**, byte-copied under `spec/` along with the device-types registry. Vendored rather than depended on because the specification is a git repository of versioned documents, not a package. They exist to be + checked against, never parsed in production: units and datatypes still come from each device's `$description`, since the catalog is the superset across all hardware rather than a statement about the panel in front of us. Formatting hooks are excluded + from `spec/`, because a lint fix there would quietly invalidate the byte comparison that makes the copies worth having. +- **`tests/test_schema_one_conformance.py`**, which asks the consumer's question rather than the publisher's. A publisher asks whether everything it emits is legal, and for it an omission is unremarkable. This asks whether every name the adapter _reads_ is + one the specification defines — because a consumer addressing a name that no longer exists does not fail, it goes quiet: the property never arrives, metadata lookup returns `None`, and an entity disappears. `ebus-sdk` 0.18.0 removing the `battery` + capability key in favour of `soc`, with no alias, is exactly that shape. +- **An explicit SPAN extension allowlist.** Fourteen of the forty-two properties this adapter reads are absent from every catalog — per-phase meter readings, panel link states, circuit `spaces`, the EVSE surface. All are legal, since the specification + permits properties it has never heard of. They are enumerated with reasons so that a name missing from the catalog must be a deliberate claim about SPAN's vocabulary rather than an unnoticed typo; at runtime the two are indistinguishable. Tests also fail + when an extension is later adopted upstream, or when one is declared for a property nothing reads. + +Provenance (byte comparison against a specification checkout) is skipped unless `EBUS_SPEC_DIR` is set, so conformance runs everywhere while the byte check stays opportunistic. Provenance proves the right bytes were copied; it cannot prove they were +understood, which is what conformance is for. + ## [0.1.0b2] - 08/2026 Pre-release. Corrects the dependency floor `0.1.0b1` shipped with, and follows the reshaped `SchemaAdapter` protocol released in `span-panel-api` 3.0.0b2. diff --git a/packages/schema-1/spec/catalogs/breaker.json b/packages/schema-1/spec/catalogs/breaker.json new file mode 100644 index 0000000..0c9e8a1 --- /dev/null +++ b/packages/schema-1/spec/catalogs/breaker.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.breaker", + "version": "0.1", + "status": "DRAFT", + "date": "2026-07-11", + "properties": { + "rating": { + "datatype": "integer", + "unit": "A", + "req": "SHOULD", + "description": "Continuous current rating." + }, + "poles": { + "datatype": "integer", + "req": "MAY", + "description": "Number of poles (1-4). A US split-phase 240 V breaker is `2`." + }, + "interrupting-rating": { + "datatype": "integer", + "unit": "kA", + "req": "MAY", + "description": "Interrupting capacity (kAIC), e.g. `10`, `65`, `100`." + }, + "protection-functions": { + "datatype": "enum", + "format": "OVERCURRENT,SHORT_CIRCUIT,GROUND_FAULT,ARC_FAULT", + "req": "MAY", + "description": "Multi-valued set of the protections this breaker provides: `OVERCURRENT`, `SHORT_CIRCUIT`, `GROUND_FAULT` (GFCI), `ARC_FAULT` (AFCI)." + }, + "trip-curve": { + "datatype": "enum", + "format": "B,C,D,K", + "req": "MAY", + "description": "Instantaneous trip curve: `B`, `C`, `D`, `K`, …" + }, + "trip-state": { + "datatype": "enum", + "format": "OK,TRIPPED,STUCK,UNKNOWN,CLOSED", + "req": "SHOULD", + "description": "`OK`, `TRIPPED`, `STUCK`, `UNKNOWN`. A tripped breaker carries no current even if a co-located `switch/relay` reads `CLOSED`, so `trip-state` is not a relay state." + }, + "trip-cause": { + "datatype": "enum", + "format": "OVERCURRENT,SHORT_CIRCUIT,GROUND_FAULT,ARC_FAULT,OVERVOLTAGE,UNKNOWN", + "req": "MAY", + "description": "Cause of the most recent trip: `OVERCURRENT`, `SHORT_CIRCUIT`, `GROUND_FAULT`, `ARC_FAULT`, `OVERVOLTAGE`, `UNKNOWN`." + } + } +} diff --git a/packages/schema-1/spec/catalogs/connection.json b/packages/schema-1/spec/catalogs/connection.json new file mode 100644 index 0000000..d4c40a7 --- /dev/null +++ b/packages/schema-1/spec/catalogs/connection.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.connection", + "version": "0.1", + "status": "DRAFT", + "date": "2026-07-05", + "properties": { + "feeds-device-id": { + "datatype": "string", + "req": "MAY", + "description": "Homie device ID of the device wired *downstream* of this connection point. Published only when the specific downstream device is known. Omitted when unknown, when mixed-load with no commissioned downstream device, or when nothing is connected." + }, + "feeds-device-type": { + "datatype": "string", + "req": "MAY", + "description": "`$description.type` of the downstream device (e.g. `energy.ebus.device.bess`, `.pv`, `.evse`, `.water-heater`, `.distribution-enclosure`, or a DER sub-device such as `.battery`). Published when the class is known even if the specific ID is not." + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "req": "MAY", + "description": "Publisher's view of communication-link health to the downstream device: `OK`, `LOST`, `DEGRADED`. Published only when `feeds-device-id` is published and the publisher has a communication integration with that device." + }, + "fed-by-device-id": { + "datatype": "string", + "req": "MAY", + "description": "Homie device ID of the device wired *upstream* of this connection point. Published only when known (e.g. an upstream BESS wired between the utility and the enclosure, or an upstream sister enclosure in a chain). Omitted when the upstream side is the utility, an implicit busbar, or unknown." + }, + "fed-by-device-type": { + "datatype": "string", + "req": "MAY", + "description": "`$description.type` of the upstream device. Published with `fed-by-device-id`." + }, + "fed-by-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "req": "MAY", + "description": "Publisher's view of communication-link health to the upstream device. Same value domain and applicability as `feeds-device-status`." + }, + "backed-up": { + "datatype": "enum", + "format": "BACKED_UP,NOT_BACKED_UP,UNKNOWN", + "req": "MAY", + "description": "Whether this path is on the backup (island) side of a microgrid interconnect device, and so stays energized off-grid: `BACKED_UP`, `NOT_BACKED_UP`, `UNKNOWN`. A wiring fact (which side of the interconnect), distinct from `load-shed/priority` (a shedding *policy*) and `grid/islanding-state` (the present *state*)." + }, + "feeds-role": { + "datatype": "enum", + "format": "LOADS,SUBPANEL,SOLAR,STORAGE,GENERATOR,MIXED,UNUSED", + "req": "MAY", + "description": "Summary role of a downstream node that is **not** published as its own eBus device, or that is surveyed-empty: `LOADS`, `SUBPANEL`, `SOLAR`, `STORAGE`, `GENERATOR`, `MIXED`, `UNUSED`. `UNUSED` positively records \"surveyed, nothing connected\" (which absence cannot express). Complements `feeds-device-*`, which is used when the downstream *is* an eBus device." + }, + "service-rating": { + "datatype": "integer", + "unit": "A", + "req": "MAY", + "description": "Utility service rating (service size) at a service-entrance connection point. Distinct from `pcs/feed-import-limit` (a PCS enforcement limit) and `breaker/rating` (a main breaker)." + }, + "overcurrent-protection": { + "datatype": "integer", + "unit": "A", + "req": "MAY", + "description": "Overcurrent-protection rating at a connection point that is not itself a breaker-protected circuit (for example a feeder conductor landing in unprotected lugs). Where the connection point *is* a breaker-protected circuit, the rating is `breaker/rating` instead." + }, + "count": { + "datatype": "integer", + "req": "MAY", + "description": "When the connected node aggregates multiple physical units behind a *single* connection point (e.g. 6 battery packs in one BESS, or 4 microinverters on one AC string reported as one solar device), how many." + } + } +} diff --git a/packages/schema-1/spec/catalogs/door.json b/packages/schema-1/spec/catalogs/door.json new file mode 100644 index 0000000..d0b35e7 --- /dev/null +++ b/packages/schema-1/spec/catalogs/door.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.door", + "version": "0.1", + "status": "DRAFT", + "date": "2026-07-11", + "properties": { + "state": { + "datatype": "enum", + "format": "OPEN,CLOSED,UNKNOWN", + "req": "MUST", + "description": "Door state: `OPEN`, `CLOSED`, `UNKNOWN`." + } + } +} diff --git a/packages/schema-1/spec/catalogs/grid.json b/packages/schema-1/spec/catalogs/grid.json new file mode 100644 index 0000000..8545bc2 --- /dev/null +++ b/packages/schema-1/spec/catalogs/grid.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.grid", + "version": "0.1", + "status": "DRAFT", + "date": "2026-07-11", + "properties": { + "islanding-state": { + "datatype": "enum", + "format": "ON_GRID,OFF_GRID,UNKNOWN", + "req": "MAY", + "description": "Whether the site is connected to or islanded from the utility: `ON_GRID`, `OFF_GRID`, `UNKNOWN`. Reflects the interconnect **relay position**. Published by the islanding authority (a MID); a device that does not sense the interconnect (a utility meter) does not publish it." + }, + "grid-state": { + "datatype": "enum", + "format": "UP,DOWN,DEGRADED,UNKNOWN", + "req": "MAY", + "description": "Sensed condition of the utility AC supply: `UP`, `DOWN`, `DEGRADED`, `UNKNOWN`. `DEGRADED` (outside the `UP` band but not a declared outage) is optional; a publisher SHOULD distinguish it when it has the measurement capability (a black-box proxied MID typically reports only `UP` / `DOWN` / `UNKNOWN`). Published by any device that senses the supply (a MID, a utility meter)." + }, + "grid-forming-entity": { + "datatype": "string", + "req": "MAY", + "description": "Identity of the device establishing the AC voltage / frequency reference: `\"GRID\"` when grid-tied, or the Homie device ID of the grid-forming device (typically the DER parent device: a BESS, a V2H EVSE, a generator) when islanded. Empty string or absent during transitions or when unknown. Published by the islanding authority (a MID)." + }, + "last-outage-time": { + "datatype": "datetime", + "req": "MAY", + "description": "Timestamp (ISO-8601 UTC) of the most recent transition from `UP` / `DEGRADED` to `DOWN` observed." + }, + "last-restoration-time": { + "datatype": "datetime", + "req": "MAY", + "description": "Timestamp (ISO-8601 UTC) of the most recent transition from `DOWN` to `UP` / `DEGRADED` observed." + } + } +} diff --git a/packages/schema-1/spec/catalogs/info.json b/packages/schema-1/spec/catalogs/info.json new file mode 100644 index 0000000..6ab0569 --- /dev/null +++ b/packages/schema-1/spec/catalogs/info.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.info", + "version": "0.2", + "status": "DRAFT", + "date": "2026-07-30", + "properties": { + "vendor-name": { + "datatype": "string", + "req": "SHOULD", + "description": "Manufacturer name (e.g., \"SPAN\", \"Tesla\", \"Rheem\")." + }, + "serial-number": { + "datatype": "string", + "req": "SHOULD", + "description": "Device serial number." + }, + "model": { + "datatype": "string", + "req": "SHOULD", + "description": "The human-facing model or product designation, the name a person recognizes (e.g., `Powerwall 3`, `IQ Battery 5P`, or a configuration code such as `MAIN_32`). This is the display designation, not the orderable part code (that is `part-number`). The valid set is publisher-defined and MAY be advertised via Homie `$format` on the property." + }, + "part-number": { + "datatype": "string", + "req": "MAY", + "description": "The vendor's orderable part or SKU code (e.g., Tesla `1232100-00-E`): the specific hardware variant beneath `model`, finer-grained so distinct part numbers (packaging, regional, or minor-revision variants) can share one `model`. A publisher that has both a coded identifier and a human-facing name publishes the code here and the designation in `model`, not a separate product-name property." + }, + "hardware-version": { + "datatype": "string", + "req": "MAY", + "description": "Hardware revision." + }, + "firmware-version": { + "datatype": "string", + "req": "SHOULD", + "description": "Firmware version. Published when the device has firmware; a bare or surveyed device (e.g., a dumb load center) omits it." + }, + "data-model-version": { + "datatype": "string", + "req": "SHOULD", + "description": "Version of the eBus data model this device publishes (e.g., `\"1.0\"`)." + }, + "nameplate-capacity": { + "datatype": "float", + "unit": "energy", + "req": "MAY", + "description": "Rated nameplate energy capacity, a term of art for energy-storage devices, reported in the device's native energy unit (a BESS in kWh electrical, a storage water heater in Wh thermal) via `$unit`. It is the static manufacturer rating: `soc` is roughly `soe` / `nameplate-capacity`, while the precise, dynamic denominator is `soc`'s `total-energy-storage` (which diverges from the nameplate figure as the reservoir degrades). Energy-storage device types (BESS, thermal storage) SHOULD publish it; power-rated devices (PV, inverter, EVSE) publish rated power via `nominal-power` instead." + } + } +} diff --git a/packages/schema-1/spec/catalogs/load-shed.json b/packages/schema-1/spec/catalogs/load-shed.json new file mode 100644 index 0000000..8b20c38 --- /dev/null +++ b/packages/schema-1/spec/catalogs/load-shed.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.load-shed", + "version": "0.3", + "status": "DRAFT", + "date": "2026-07-30", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,NEVER,OFF_GRID,SOC_THRESHOLD", + "settable": true, + "req": "SHOULD", + "description": "The circuit's shedding class. Baseline (every host): `UNKNOWN`, `NEVER`, `OFF_GRID`. Optional additional triggers are advertised in the property's `$format`: `SOC_THRESHOLD` and future spec- or vendor-defined values." + } + } +} diff --git a/packages/schema-1/spec/catalogs/meter.json b/packages/schema-1/spec/catalogs/meter.json new file mode 100644 index 0000000..dafe790 --- /dev/null +++ b/packages/schema-1/spec/catalogs/meter.json @@ -0,0 +1,201 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.meter", + "version": "0.2", + "status": "DRAFT", + "date": "2026-07-31", + "properties": { + "active-power": { + "datatype": "float", + "unit": "W", + "req": "MAY", + "description": "Total active power. Sign per the reference-direction rule below." + }, + "reactive-power": { + "datatype": "float", + "unit": "var", + "req": "MAY", + "description": "Total reactive power." + }, + "apparent-power": { + "datatype": "float", + "unit": "VA", + "req": "MAY", + "description": "Total apparent power." + }, + "power-factor": { + "datatype": "float", + "format": "-1.0:1.0", + "req": "MAY", + "description": "System power factor, signed: positive = lagging (inductive), negative = leading (capacitive); range `[-1.0, 1.0]`." + }, + "frequency": { + "datatype": "float", + "unit": "Hz", + "req": "MAY", + "description": "Line frequency." + }, + "voltage": { + "datatype": "float", + "unit": "V", + "req": "MAY", + "description": "RMS voltage at a single-point meter (one measured conductor, e.g. a branch circuit or a device's single AC boundary). A split-phase or three-phase meter uses the per-conductor `voltage-{a,b,c}` instead." + }, + "current": { + "datatype": "float", + "unit": "A", + "req": "MAY", + "description": "RMS current at a single-point meter (one measured conductor). A split-phase or three-phase meter uses the per-conductor `current-{a,b,c,n}` instead." + }, + "imported-energy": { + "datatype": "float", + "unit": "Wh", + "req": "MAY", + "description": "Cumulative active energy imported: the energy counterpart of positive `active-power` (into the metered device / consumption in the default frame; which register accrues follows the reference-direction rule below). Monotonically non-decreasing." + }, + "exported-energy": { + "datatype": "float", + "unit": "Wh", + "req": "MAY", + "description": "Cumulative active energy exported: the energy counterpart of negative `active-power` (out of the metered device / production or backfeed in the default frame; which register accrues follows the reference-direction rule below). Monotonically non-decreasing." + }, + "imported-reactive-energy": { + "datatype": "float", + "unit": "varh", + "req": "MAY", + "description": "Cumulative reactive energy imported." + }, + "exported-reactive-energy": { + "datatype": "float", + "unit": "varh", + "req": "MAY", + "description": "Cumulative reactive energy exported." + }, + "apparent-energy-imported": { + "datatype": "float", + "unit": "VAh", + "req": "MAY", + "description": "Cumulative apparent energy imported." + }, + "apparent-energy-exported": { + "datatype": "float", + "unit": "VAh", + "req": "MAY", + "description": "Cumulative apparent energy exported." + } + }, + "property_patterns": { + "voltage-{a,b,c}": { + "datatype": "float", + "unit": "V", + "req": "MAY", + "description": "RMS voltage on the named phase, line-to-neutral (or line-to-virtual-neutral on a delta service).", + "expand": [ + "a", + "b", + "c" + ] + }, + "current-{a,b,c,n}": { + "datatype": "float", + "unit": "A", + "req": "MAY", + "description": "RMS current on the named conductor. Neutral current (`current-n`) may be measured or imputed.", + "expand": [ + "a", + "b", + "c", + "n" + ] + }, + "active-power-{a,b,c}": { + "datatype": "float", + "unit": "W", + "req": "MAY", + "description": "Per-phase active power. Sign matches the system `active-power`.", + "expand": [ + "a", + "b", + "c" + ] + }, + "reactive-power-{a,b,c}": { + "datatype": "float", + "unit": "var", + "req": "MAY", + "description": "Per-phase reactive power.", + "expand": [ + "a", + "b", + "c" + ] + }, + "apparent-power-{a,b,c}": { + "datatype": "float", + "unit": "VA", + "req": "MAY", + "description": "Per-phase apparent power.", + "expand": [ + "a", + "b", + "c" + ] + }, + "power-factor-{a,b,c}": { + "datatype": "float", + "req": "MAY", + "description": "Per-phase power factor, signed as for the system value.", + "expand": [ + "a", + "b", + "c" + ] + }, + "voltage-angle-{a,b,c}": { + "datatype": "float", + "unit": "°", + "req": "MAY", + "description": "Voltage angle relative to phase-A voltage (`voltage-angle-a` = `0`).", + "expand": [ + "a", + "b", + "c" + ] + }, + "current-angle-{a,b,c}": { + "datatype": "float", + "unit": "°", + "req": "MAY", + "description": "Current angle relative to the same-phase voltage.", + "expand": [ + "a", + "b", + "c" + ] + }, + "imported-energy-{a,b,c}": { + "datatype": "float", + "unit": "Wh", + "req": "MAY", + "description": "Per-conductor cumulative imported energy.", + "expand": [ + "a", + "b", + "c" + ] + }, + "exported-energy-{a,b,c}": { + "datatype": "float", + "unit": "Wh", + "req": "MAY", + "description": "Per-conductor cumulative exported energy.", + "expand": [ + "a", + "b", + "c" + ] + } + } +} diff --git a/packages/schema-1/spec/catalogs/pcs.json b/packages/schema-1/spec/catalogs/pcs.json new file mode 100644 index 0000000..cb1e366 --- /dev/null +++ b/packages/schema-1/spec/catalogs/pcs.json @@ -0,0 +1,111 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.pcs", + "version": "0.3", + "status": "DRAFT", + "date": "2026-07-14", + "properties": { + "enabled": { + "datatype": "boolean", + "req": "SHOULD", + "description": "Is the PCS enabled on this enclosure?" + }, + "active": { + "datatype": "boolean", + "req": "SHOULD", + "description": "Is the PCS actively limiting import right now?" + }, + "import-limit": { + "datatype": "float", + "unit": "A", + "req": "SHOULD", + "description": "The **effective** enforced import limit: the `min()` across all active constraints reconciled to amps (the amps-native limits below, plus the reconciled `doe` and `voltage-response`)." + }, + "binding-constraint": { + "datatype": "enum", + "format": "FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN", + "req": "SHOULD", + "description": "Which constraint class currently sets `import-limit`: `FSR`, `DOE`, `VOLTAGE`, `OFF_GRID`, `REQUESTED`, `OPERATOR`, `NONE`, `UNKNOWN`. The provenance of the enforced limit; publishers MAY extend via `$format` (see the note on vendor-specific sources below)." + }, + "feed-import-limit": { + "datatype": "float", + "unit": "A", + "req": "SHOULD", + "description": "The **FSR**: commissioned firm feed / service capacity (premises-equipment protection), set at install. The always-on floor. May be less than the main-breaker rating when the upstream feed conductor is smaller (e.g. a 200 A panel on a 100 A service feed publishes `feed-import-limit = 100`)." + }, + "feed-import-limit-enablement": { + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", + "req": "SHOULD", + "description": "`UNSPECIFIED`, `UNCONFIGURED`, `DISABLED`, `ENABLED`." + }, + "feed-import-limit-active": { + "datatype": "boolean", + "req": "SHOULD", + "description": "Is this constraint currently enforcing (enabled **and** its activation conditions met)? Distinct from `binding-constraint`: several constraints may be active at once, but only the most restrictive is binding." + }, + "off-grid-import-limit": { + "datatype": "float", + "unit": "A", + "req": "MAY", + "description": "Import cap when islanded (from BESS / DER)." + }, + "off-grid-import-limit-enablement": { + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", + "req": "MAY", + "description": "Same domain." + }, + "off-grid-import-limit-active": { + "datatype": "boolean", + "req": "MAY", + "description": "Typically active only while islanded. See `feed-import-limit-active`." + }, + "requested-import-limit": { + "datatype": "float", + "unit": "A", + "req": "MAY", + "description": "A **voluntary**, self-imposed temporary limit requested by the homeowner or installer (e.g. via a mobile app). Self-revocable. Distinct from an externally imposed operator cap (`operator-import-limit`) and from the utility grid envelope (`doe`)." + }, + "requested-import-limit-enablement": { + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", + "req": "MAY", + "description": "Same domain." + }, + "requested-import-limit-active": { + "datatype": "boolean", + "req": "MAY", + "description": "See `feed-import-limit-active`." + }, + "operator-import-limit": { + "datatype": "float", + "unit": "A", + "req": "MAY", + "description": "An **externally imposed** cap set by a fleet / aggregator operator over a management API (a DER aggregator, VPP, or utility program acting through the vendor's fleet REST interface). Persists until the operator changes or clears it. Distinct from `requested-import-limit` (self-imposed) and from `doe` (the standardized IEEE 2030.5 / CSIP watts envelope): `operator-import-limit` is a vendor-API amps cap, not a CSIP DOE." + }, + "operator-import-limit-enablement": { + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", + "req": "MAY", + "description": "Same domain." + }, + "operator-import-limit-active": { + "datatype": "boolean", + "req": "MAY", + "description": "See `feed-import-limit-active`." + }, + "managed": { + "datatype": "boolean", + "req": "MAY", + "description": "Is this circuit managed by the host's PCS?" + }, + "priority": { + "datatype": "integer", + "req": "MAY", + "description": "PCS priority ranking, consulted when an active import limit is binding (which circuits shed first)." + } + } +} diff --git a/packages/schema-1/spec/catalogs/power-flows.json b/packages/schema-1/spec/catalogs/power-flows.json new file mode 100644 index 0000000..410ca8f --- /dev/null +++ b/packages/schema-1/spec/catalogs/power-flows.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.power-flows", + "version": "0.1", + "status": "DRAFT", + "date": "2026-07-11", + "properties": { + "grid": { + "datatype": "float", + "unit": "W", + "req": "SHOULD", + "description": "Grid power flow (positive = importing from grid)." + }, + "battery": { + "datatype": "float", + "unit": "W", + "req": "SHOULD", + "description": "Battery power flow (positive = discharging)." + }, + "pv": { + "datatype": "float", + "unit": "W", + "req": "SHOULD", + "description": "Solar PV power flow (positive = producing)." + }, + "site": { + "datatype": "float", + "unit": "W", + "req": "SHOULD", + "description": "Total site power consumption." + } + } +} diff --git a/packages/schema-1/spec/catalogs/shed.json b/packages/schema-1/spec/catalogs/shed.json new file mode 100644 index 0000000..ff1a1dd --- /dev/null +++ b/packages/schema-1/spec/catalogs/shed.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.shed", + "version": "0.2", + "status": "DRAFT", + "date": "2026-07-30", + "properties": { + "asserted-islanding-state": { + "datatype": "enum", + "format": "NONE,ON_GRID,OFF_GRID", + "settable": true, + "req": "MAY", + "description": "Consumer-asserted islanding-state for the host's own island scope, consulted only while the host has lost or degraded communication with the device that senses that state (its MID / BESS). Advertised in `$format` as `NONE`, `ON_GRID`, `OFF_GRID` (default `NONE`). See \"Asserted islanding-state\" below." + }, + "policy": { + "datatype": "json", + "settable": true, + "req": "MAY", + "description": "The host's shedding algorithm and its parameters: `{ \"algorithm\": , \"parameters\": { … } }`. The parameter object's shape is advertised as a JSON Schema in the property's `$format`. See \"Shed policy\" below." + } + } +} diff --git a/packages/schema-1/spec/catalogs/soc.json b/packages/schema-1/spec/catalogs/soc.json new file mode 100644 index 0000000..2885427 --- /dev/null +++ b/packages/schema-1/spec/catalogs/soc.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.soc", + "version": "0.1", + "status": "DRAFT", + "date": "2026-07-11", + "properties": { + "soc": { + "datatype": "float", + "unit": "%", + "req": "MAY", + "description": "State of charge: the fraction of capacity currently held (`0` = empty, `100` = full). A dimensionless ratio, comparable across all reservoirs." + }, + "soe": { + "datatype": "float", + "unit": "energy", + "req": "MAY", + "description": "State of energy: the energy currently stored and available to draw (the discharge side). Reported in the device's native energy unit (a BESS in kWh electrical, a water heater in Wh thermal)." + }, + "total-energy-storage": { + "datatype": "float", + "unit": "energy", + "req": "MAY", + "description": "The reservoir's total energy capacity (empty to full), in the same unit as `soe`." + }, + "loadup-headroom": { + "datatype": "float", + "unit": "energy", + "req": "MAY", + "description": "The energy the reservoir can absorb **now** (the charge side), approximately `total-energy-storage − soe`. The dispatchable charge a load-up / charge action can take on." + } + } +} diff --git a/packages/schema-1/spec/catalogs/status.json b/packages/schema-1/spec/catalogs/status.json new file mode 100644 index 0000000..65f0731 --- /dev/null +++ b/packages/schema-1/spec/catalogs/status.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.status", + "version": "0.1", + "status": "DRAFT", + "date": "2026-07-11", + "properties": { + "fault-state": { + "datatype": "enum", + "format": "OK,FAULT,UNKNOWN", + "req": "MAY", + "description": "Overall fault state: `OK`, `FAULT`, `UNKNOWN`. Publishers MAY extend the value set via `$format` for device-specific fault categories." + }, + "communication-state": { + "datatype": "enum", + "format": "OK,DEGRADED,LOST,UNKNOWN", + "req": "MAY", + "description": "The publisher's view of its own communication / link health, to the device it represents (for a proxy) or to its backhaul (for a native device): `OK`, `DEGRADED`, `LOST`, `UNKNOWN`. Orthogonal to whether the eBus publisher is currently reporting to *its* consumers." + }, + "active-alerts": { + "datatype": "string", + "req": "MAY", + "description": "Human-readable current alert(s), when the device exposes them." + } + } +} diff --git a/packages/schema-1/spec/catalogs/switch.json b/packages/schema-1/spec/catalogs/switch.json new file mode 100644 index 0000000..13d44cb --- /dev/null +++ b/packages/schema-1/spec/catalogs/switch.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.switch", + "version": "0.1", + "status": "DRAFT", + "date": "2026-07-05", + "properties": { + "relay": { + "datatype": "enum", + "format": "OPEN,CLOSED,UNKNOWN", + "settable": true, + "req": "MUST", + "description": "Relay state: `OPEN`, `CLOSED`, `UNKNOWN`. Settable when `relay-controllable = true`." + }, + "relay-controllable": { + "datatype": "boolean", + "req": "SHOULD", + "description": "True = the relay can be opened and closed by command or automatic shed. False = locked (for example a circuit commissioned as permanently on)." + }, + "relay-requester": { + "datatype": "enum", + "format": "USER,LOAD_SHED,PCS,CONFIGURATION,FAULT,NONE,UNKNOWN", + "req": "SHOULD", + "description": "Source attribution for the last relay change: `USER`, `LOAD_SHED`, `PCS`, `CONFIGURATION`, `FAULT`, `NONE`, `UNKNOWN`. Publishers MAY extend via `$format`." + } + } +} diff --git a/packages/schema-1/spec/registries/device-types.md b/packages/schema-1/spec/registries/device-types.md new file mode 100644 index 0000000..fb987b7 --- /dev/null +++ b/packages/schema-1/spec/registries/device-types.md @@ -0,0 +1,56 @@ +# Electrification Bus Device Type Registry + +**Status:** DRAFT v0.5 +**Date:** 2026-08-05 +**Authors:** Don Jackson + +## Purpose + +This document is the canonical registry of `energy.ebus.device.*` device-type identifiers used across all Electrification Bus (eBus for short) data models. Data-model documents reference identifiers from this registry; new identifiers are added to this registry when a data-model document introduces them. + +In the eBus Homie model, every device participating in the bus declares its device type via the `$type` attribute drawn from this namespace. A device-type identifier names a category of physical or logical device — for example, a distribution enclosure, a battery energy storage system, an electric vehicle supply equipment unit — and constrains what device structure (child devices, capabilities) the parent of that type is expected to expose. + +This registry is descriptive, not exhaustive: it lists what is currently registered. Consumers MUST tolerate unknown `$type` values (e.g., accept and persist them; apply only generic Homie handling). + +## Format rules + +- Identifiers are of the form `energy.ebus.device.`. +- The `` portion is lowercase kebab-case ASCII: lowercase letters, digits, and hyphens only. +- No leading or trailing hyphens; no consecutive hyphens. +- Identifiers are case-sensitive. + +## Registered device types + +The **Source** column references the data-model document where the identifier currently appears. For identifiers that appear only as forward references (the full data model has not yet been published), the source is the document that introduced the reference. + +| Identifier | Description | Source | +|---|---|---| +| `energy.ebus.device.distribution-enclosure` | Parent device for an electrical distribution enclosure (panel / load center / consumer unit / switchboard). Hosts child devices for circuits, feed points, and (in some installations) integrated DERs. | [`devices/distribution-enclosure.md`](../devices/distribution-enclosure.md) | +| `energy.ebus.device.circuit` | Child device representing one branch circuit within a distribution enclosure. | [`devices/distribution-enclosure.md`](../devices/distribution-enclosure.md) | +| `energy.ebus.device.lugs` | Child device representing a feed point (upstream or downstream lugs) on a distribution enclosure. Carries the meter for that feed. | [`devices/distribution-enclosure.md`](../devices/distribution-enclosure.md) | +| `energy.ebus.device.bess` | Battery Energy Storage System (whole-home grid-forming, plug-in / UPS, or grid-following-only). May be published natively (as its own Homie root) or proxied as a child of a distribution enclosure. | [`devices/bess.md`](../devices/bess.md) | +| `energy.ebus.device.pv` | Photovoltaic inverter. May be published natively or proxied as a child of a distribution enclosure. *Full data model pending — currently described in the dist-enclosure spec via the proxied-PV child structure.* | [`devices/distribution-enclosure.md`](../devices/distribution-enclosure.md) (forward reference) | +| `energy.ebus.device.evse` | Electric Vehicle Supply Equipment. May be published natively or proxied as a child of a distribution enclosure. *Full data model pending — currently described in the dist-enclosure spec via the proxied-EVSE child structure.* | [`devices/distribution-enclosure.md`](../devices/distribution-enclosure.md) (forward reference) | +| `energy.ebus.device.mid` | Microgrid Interconnect Device — the grid-relay + per-side meters + controller subsystem that handles islanding for a grid-forming-capable site. May appear as a child device of a distribution enclosure (enclosure-integrated MID) or a BESS (BESS-integrated MID or its proxied equivalent); may also be published as a first-class standalone device. *Full data model pending — currently described in the dist-enclosure spec's MID and proxied-BESS sections.* | [`devices/distribution-enclosure.md`](../devices/distribution-enclosure.md) (forward reference) | +| `energy.ebus.device.bridge` | Standalone proxy host — an entity whose sole role is to bridge one or more non-eBus-native devices into the eBus tree (e.g., a Linux service polling a Tesla cloud API and publishing the Powerwall as a proxy; a Modbus-to-eBus appliance; a CTA-2045 Universal Communications Module bridging a water heater). The bridge anchors the Homie tree as the root of its proxied children; the proxied devices are named per the `{proxier-id}-{proxied-id}` convention in `devices/proxy.md`. The bridge does not publish HEI-device capabilities of its own. Semantic parallel to Matter's *Bridge* device type. | [`framework.md`](../framework.md#standalone-proxy-hosts-bridges) | +| `energy.ebus.device.pdu` | Parent device for a Power Distribution Unit: distributes power to switchable, metered `outlet` children. No storage and no generation. | [`devices/pdu.md`](../devices/pdu.md) | +| `energy.ebus.device.outlet` | One switchable, metered output port (an AC receptacle, or a USB / DC port). Used as a child of a host (PDU, plug-in BESS / UPS) or standalone (a smart plug / smart receptacle). | [`devices/outlet.md`](../devices/outlet.md) | +| `energy.ebus.device.water-heater` | A storage water heater (heat-pump, electric-resistance, gas, or hybrid) modeled as a controllable, grid-flexible load and dispatchable thermal-storage resource. May be published natively or proxied (e.g., as the child of a CTA-2045 UCM bridge). | [`devices/water-heater.md`](../devices/water-heater.md) | +| `energy.ebus.device.utility-meter` | The revenue-grade metering device installed by an electric utility at a customer's service entrance, between the utility's distribution system and the premises wiring. The site's primary point of measurement for energy billing and its most authoritative observer of the utility supply. May be published natively or by a proxy publisher with access to the underlying values. | [`devices/utility-meter.md`](../devices/utility-meter.md) | +| `energy.ebus.device.battery` | Individual battery pack. Child of a BESS. | [`devices/bess.md`](../devices/bess.md) | +| `energy.ebus.device.inverter` | DC-AC inverter. May handle both battery and solar (e.g., Powerwall 3). Child of a BESS. | [`devices/bess.md`](../devices/bess.md) | +| `energy.ebus.device.meter` | Metering point within a larger system, used for site, load, or solar metering. Child of a BESS. Distinct from `utility-meter`, which is the utility's revenue meter at the service entrance. | [`devices/bess.md`](../devices/bess.md) | + +## Adding new device types + +New identifiers may be added to this registry as new data-model documents are published. The process: + +1. The proposed identifier follows the format rules above. +2. The proposed identifier is genuinely new — not a synonym of an existing entry. +3. A data-model document defines (or explicitly references) the device and is the source for the registry row. +4. The proposed identifier is added to this registry with a description and a source reference. +5. This document's version is bumped. + +Forward references (a data model that mentions a device type whose full model isn't published yet) are valid registry entries; they are marked as such in the Source column and re-pointed at the full data model when it lands. + +Producers and consumers SHOULD treat unknown `$type` values as opaque — accept and persist them, but apply only the generic Homie / eBus framework defaults. This permits forward-compatibility: a device using a newer type identifier than the consumer knows about should still be handled gracefully. diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json new file mode 100644 index 0000000..19e0a58 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://ebus.energy/schemas/ebus-spec.json", + "role": "consumer", + "firmware": { + "family": "spanos2", + "range": "r202633+", + "data_model_version": ">=1.0,<2.0" + }, + "spec_repo": "https://github.com/electrification-bus/specification", + "synced_commit": "4254526b0a8c11cab0d40fd700b1fc295c0479c6", + "synced_date": "2026-08-06", + "framework": "0.7", + "implements": { + "capabilities": { + "breaker": "0.1", + "connection": "0.1", + "door": "0.1", + "grid": "0.1", + "info": "0.2", + "load-shed": "0.3", + "meter": "0.2", + "pcs": "0.3", + "power-flows": "0.1", + "shed": "0.2", + "soc": "0.1", + "status": "0.1", + "switch": "0.1" + }, + "devices": { + "distribution-enclosure": "0.12", + "circuit": "0.3", + "bess": "0.14" + }, + "registries": { + "capability-types": "0.19", + "device-types": "0.5" + } + }, + "notes": "role=consumer: span-panel-api-schema-1 parses the Homie 5 distribution-enclosure tree that SPAN firmware r202633+ publishes, and is hot-loaded by span-panel-api through the span_panel_api.schema_adapters entry-point group. It is the consumer counterpart to SpanPanel/simulator (role=publisher), which is pinned to the same synced_commit; the shared anchor between them is the firmware range above, not this commit, because the spec says what a device class MAY publish while a panel publishes one specific tree. PROVENANCE: packages/schema-1/spec/catalogs/*.json are byte copies of the specification's capabilities/ at synced_commit, and spec/registries/device-types.md is a byte copy of that registry. They are verified by byte comparison when a specification checkout is available (EBUS_SPEC_DIR); the comparison skips when none is, so the conformance check below always runs while the provenance check is opportunistic. Never hand-edit anything under spec/ -- an edit makes the byte comparison meaningless. WHAT IS VENDORED AND WHY SO LITTLE: only the 13 capability catalogs this adapter addresses, because a consumer needs the vocabulary it reads and nothing else. Datatypes, units and formats are deliberately NOT taken from these catalogs at runtime: the adapter reads them from each device's $description, because the same capability exposes different properties on different device classes (meter is voltage on the panel, power and energy on a circuit, both currents on lugs) and the catalog is the superset across all hardware rather than a statement about this panel. The vendored copies exist to be checked against, not to be parsed in production. ABSTRACT UNITS: four catalog properties carry unit: energy, a dimension rather than a unit (conventions/property-json.md 0.2). Being description-driven makes this adapter correct here by construction, and a test asserts it rather than leaving it to luck. EXTENSIONS: SPAN publishes properties no catalog defines -- per-phase meter readings, panel status links, circuit spaces. Those are legal under the specification and are enumerated as an explicit allowlist in tests/test_schema_one_conformance.py, so a name that is absent from the catalog has to be declared deliberately rather than assumed. PINNING RULE: pin what this adapter actually reads AND that exists in the current spec. pv/evse/mid/lugs have no standalone versioned device model upstream and are covered transitively as child device_types of distribution-enclosure 0.12, so they are not separately pinned." +} diff --git a/tests/test_schema_one_conformance.py b/tests/test_schema_one_conformance.py new file mode 100644 index 0000000..5b9459d --- /dev/null +++ b/tests/test_schema_one_conformance.py @@ -0,0 +1,244 @@ +"""Conformance checks — is every name this adapter reads one the eBus spec defines? + +The consumer counterpart to `test_schema_provenance.py`, which does the same job +for the flat adapter against SPAN's own schema document. This one runs against +vendored copies of the eBus capability catalogs, because v1.0 vocabulary comes +from the specification rather than from a per-panel schema. + +**The direction matters, and it is not the publisher's.** The simulator asks "is +everything I publish legal?", and for it an omission is legal and abundant. This +asks the opposite question: is everything we *read* actually defined? A consumer +addressing a name the spec no longer carries does not fail — the property simply +never arrives, a metadata lookup returns None, and an entity goes missing. That +has already happened upstream once: `ebus-sdk` 0.18.0 removed the `battery` +capability key outright in favour of `soc`, with no alias. A consumer hardcoding +`battery` would have gone quiet rather than broken. + +Two checks with different reach, deliberately: + +- **Conformance** (below) compares this adapter against the vendored catalogs and + always runs, so CI needs no network and no specification checkout. +- **Provenance** (the last test) compares the vendored catalogs against the + specification itself, and skips unless `EBUS_SPEC_DIR` points at a checkout. + +Provenance proves we copied the right bytes; it cannot prove we understood them. +Conformance is where the understanding gets checked. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re + +import pytest + +from span_panel_api_schema_1 import const +from span_panel_api_schema_1.field_metadata import _PROPERTY_FIELD_MAP + +_SPEC = Path(__file__).parent.parent / "packages" / "schema-1" / "spec" +_CATALOGS = _SPEC / "catalogs" +_DEVICE_TYPES = _SPEC / "registries" / "device-types.md" +_LOCK = Path(const.__file__).parent / "spec_lock.json" + + +def _lock() -> dict[str, object]: + with _LOCK.open() as handle: + loaded: dict[str, object] = json.load(handle) + return loaded + + +def _catalog(node: str) -> dict[str, object]: + with (_CATALOGS / f"{node}.json").open() as handle: + loaded: dict[str, object] = json.load(handle) + return loaded + + +def _catalog_properties(node: str) -> set[str]: + properties = _catalog(node).get("properties", {}) + assert isinstance(properties, dict) + return set(properties) + + +# Properties this adapter reads that no catalog defines. +# +# These are legal: the specification lets a publisher emit properties it has +# never heard of, and SPAN does. They are listed rather than tolerated so that a +# name missing from the catalog has to be a deliberate claim about SPAN's own +# vocabulary, not an unnoticed typo — the two are indistinguishable at runtime, +# since both produce a property that never arrives. +_SPAN_EXTENSIONS: dict[tuple[str, str], str] = { + (const.NODE_STATUS, "relay"): "panel main relay position; the catalog's status is alerts and comms only", + (const.NODE_STATUS, "ethernet"): "panel ethernet link state", + (const.NODE_STATUS, "wifi"): "panel wifi link state", + (const.NODE_STATUS, "cloud-connection"): "panel vendor-cloud reachability", + (const.NODE_STATUS, "status"): "EVSE session status", + (const.NODE_METER, "voltage-a"): "split-phase per-leg voltage; the catalog carries a single voltage", + (const.NODE_METER, "voltage-b"): "split-phase per-leg voltage; the catalog carries a single voltage", + (const.NODE_METER, "current-a"): "split-phase per-leg current; the catalog carries a single current", + (const.NODE_METER, "current-b"): "split-phase per-leg current; the catalog carries a single current", + (const.NODE_METER, "advertised-current"): "EVSE pilot-advertised current", + (const.NODE_INFO, "name"): "circuit label; Homie's $name is the device name, not the circuit's", + (const.NODE_INFO, "spaces"): "breaker spaces occupied, a load-centre concept the catalog has no room for", + (const.NODE_INFO, "nominal-power"): ( + "PV AC power rating in W. Deliberately not the catalog's nameplate-capacity, " + "which is stored energy with an abstract unit — a different quantity with a confusable name." + ), + (const.NODE_SWITCH, "lock-state"): "EVSE connector lock", +} + + +# --------------------------------------------------------------------------- +# The lockfile describes what is actually vendored +# --------------------------------------------------------------------------- + + +def test_every_pinned_capability_is_vendored_at_the_pinned_version() -> None: + """A pin that names a version the vendored file does not carry is worse than + no pin: it reports provenance that was never true.""" + pinned = _lock()["implements"] + assert isinstance(pinned, dict) + capabilities = pinned["capabilities"] + assert isinstance(capabilities, dict) + + mismatched = [ + f"{node}: lockfile says {version}, catalog says {_catalog(node).get('version')}" + for node, version in capabilities.items() + if _catalog(node).get("version") != version + ] + + assert not mismatched, "lockfile disagrees with the vendored catalogs:\n " + "\n ".join(mismatched) + + +def test_every_capability_node_this_adapter_reads_has_a_vendored_catalog() -> None: + """Adding a NODE_* to const.py without vendoring its catalog would leave that + node's properties unchecked while looking checked.""" + read = {value for name, value in vars(const).items() if name.startswith("NODE_") and isinstance(value, str)} + vendored = {path.stem for path in _CATALOGS.glob("*.json")} + + assert read <= vendored, f"capability nodes read but not vendored: {sorted(read - vendored)}" + + +# --------------------------------------------------------------------------- +# The core assertion — every name resolves, or is a declared extension +# --------------------------------------------------------------------------- + + +def test_every_mapped_property_is_catalogued_or_a_declared_extension() -> None: + """`_PROPERTY_FIELD_MAP` is the adapter's statement of what it reads. Every + row must be a property the specification defines, or one this file declares + SPAN publishes on its own account.""" + undeclared = [ + f"{device_type} {node}/{property_id} -> {field_path}" + for device_type, node, property_id, field_path in _PROPERTY_FIELD_MAP + if property_id not in _catalog_properties(node) and (node, property_id) not in _SPAN_EXTENSIONS + ] + + assert not undeclared, ( + "properties read by this adapter that no catalog defines and no extension declares:\n " + + "\n ".join(undeclared) + + "\n\nEither the specification moved and the adapter must follow, or this is a SPAN " + "extension and belongs in _SPAN_EXTENSIONS with a reason." + ) + + +def test_no_declared_extension_has_been_adopted_by_the_specification() -> None: + """The reverse direction. When upstream adopts a name we carried as an + extension, the entry becomes wrong — and silently so, because everything + still works. This converts that into a visible prompt to re-read the catalog, + since an adopted property may be specified differently than SPAN publishes it. + """ + adopted = [ + f"{node}/{property_id} — {reason}" + for (node, property_id), reason in _SPAN_EXTENSIONS.items() + if property_id in _catalog_properties(node) + ] + + assert not adopted, ( + "declared as SPAN extensions but now in the catalog:\n " + + "\n ".join(adopted) + + "\n\nCompare the catalog's definition against what SPAN publishes, then drop the entry." + ) + + +def test_no_extension_is_declared_for_a_property_nothing_reads() -> None: + """An allowlist that outlives its use quietly grants permission for names the + adapter no longer has, which is how allowlists rot.""" + read = {(node, property_id) for _, node, property_id, _ in _PROPERTY_FIELD_MAP} + unused = sorted(pair for pair in _SPAN_EXTENSIONS if pair not in read) + + assert not unused, f"extensions declared for properties nothing reads: {unused}" + + +def test_every_device_class_is_in_the_device_types_registry() -> None: + """The seven classes the mapper sorts the tree by. A class the registry drops + means SPAN is publishing something eBus no longer names.""" + registry = _DEVICE_TYPES.read_text(encoding="utf-8") + registered = set(re.findall(r"`(energy\.ebus\.device\.[a-z-]+)`", registry)) + read = {value for name, value in vars(const).items() if name.startswith("TYPE_") and isinstance(value, str)} + + assert read <= registered, f"device classes not in the registry: {sorted(read - registered)}" + + +# --------------------------------------------------------------------------- +# The rule that does not travel with a vendored file +# --------------------------------------------------------------------------- + + +def test_an_abstract_unit_is_never_taken_from_the_catalog() -> None: + """`unit: "energy"` names a dimension, not a unit — a BESS reports kWh, a + water heater Wh — and the specification requires a publisher to substitute a + real one. A consumer that trusted the catalog would hand the integration the + placeholder as though it were a unit. + + This adapter is right by construction, because it reads units from each + device's `$description` rather than from any catalog. That is worth asserting + rather than assuming: the catalog is vendored right here, and reaching for it + is the obvious shortcut the day someone wants a unit the description omits. + """ + abstract = { + (node, property_id) + for node in ("soc", "info") + for property_id, definition in _catalog(node).get("properties", {}).items() # type: ignore[union-attr] + if isinstance(definition, dict) and definition.get("unit") == "energy" + } + + assert abstract, "no catalog property carries an abstract unit; this test no longer guards anything" + assert (const.NODE_SOC, "soe") in abstract, "soc/soe is the one this adapter reads; the catalog no longer marks it" + + metadata_source = (Path(const.__file__).parent / "field_metadata.py").read_text(encoding="utf-8") + assert "spec_lock" not in metadata_source and "catalogs" not in metadata_source, ( + "field_metadata.py now references the vendored spec. Units must come from each device's " + "$description; the catalog is the superset across all hardware and carries abstract units." + ) + + +# --------------------------------------------------------------------------- +# Provenance — opportunistic, because it needs a checkout +# --------------------------------------------------------------------------- + + +def test_vendored_catalogs_are_byte_identical_to_the_specification() -> None: + """Byte comparison against the specification at `synced_commit`. + + Skipped rather than failed without a checkout: the conformance checks above + are the ones that must run everywhere, and making all of them depend on a + second repository would mean they stop running. + """ + spec_dir = os.environ.get("EBUS_SPEC_DIR") + if not spec_dir: + pytest.skip("set EBUS_SPEC_DIR to a specification checkout to verify vendored bytes") + + spec = Path(spec_dir) + lock = _lock() + differing = [ + path.name + for path in sorted(_CATALOGS.glob("*.json")) + if (spec / "capabilities" / path.name).read_bytes() != path.read_bytes() + ] + + assert not differing, ( + f"vendored catalogs differ from {spec_dir} (lockfile pins {lock['synced_commit']}): {differing}. " + "Check the checkout is at synced_commit before assuming the copies are wrong." + ) From e03ea8159730319e76f341528d1929d35de229a6 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:02:55 -0700 Subject: [PATCH 038/115] feat(schema_1): pair the parser against the producer it is developed against Adds the cross-repo half of the wire-contract design: spec_lock.json records the SPAN simulator as peer (role=publisher) with the specification commit and firmware range it pins, and the tree it publishes is vendored alongside the catalogs. Publisher and consumer reading different vocabularies is now a test failure. The anchor asserted between them is the firmware range, not the spec commit -- the specification says what a device class may publish, while a panel publishes one specific tree. Deliberately not a submodule. The dependency direction would be wrong: this is a published library and the simulator is a test tool, so the library's repo would carry a pointer to its own fixture producer, and every release checkout would drag in an HA add-on it has no use for. Vendoring a captured artifact with recorded provenance is the pattern already proven for the catalogs, and it does not care whether the producer lives on a branch or in its own repository later -- only a URL string changes. Fixes the read set the conformance check was built on. Derived from _PROPERTY_FIELD_MAP alone, it covered only properties carrying field metadata and skipped everything the snapshot mapper reads directly: the MID, connection feeds/fed-by, info/direction. grid_state -- the most recently corrected mapping in this package -- was among them, so the check that exists to catch silent absence was itself silently incomplete. The set is now derived from the source by walking each module for reader calls taking a NODE_/PROP_ pair, resolving constants per module because they do not all live in const.py. That immediately surfaced info/direction as a fifteenth undeclared extension. Coverage is now measured rather than assumed: of 42 pairs read, the captured tree declares 41. The exception is grid/islanding-state, because the simulator models a MID but its tracked config publishes none. Recorded explicitly, since a passing suite otherwise reads as coverage it does not have, and rejected once the simulator starts publishing it. --- packages/schema-1/CHANGELOG.md | 16 +- .../spec/fixtures/simulator_tree.json | 4924 +++++++++++++++++ .../span_panel_api_schema_1/spec_lock.json | 9 + tests/test_schema_one_conformance.py | 244 +- 4 files changed, 5164 insertions(+), 29 deletions(-) create mode 100644 packages/schema-1/spec/fixtures/simulator_tree.json diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index ecc1ffb..17f825c 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -23,9 +23,21 @@ number. A release here means this parser changed, never that the panel did. - **An explicit SPAN extension allowlist.** Fourteen of the forty-two properties this adapter reads are absent from every catalog — per-phase meter readings, panel link states, circuit `spaces`, the EVSE surface. All are legal, since the specification permits properties it has never heard of. They are enumerated with reasons so that a name missing from the catalog must be a deliberate claim about SPAN's vocabulary rather than an unnoticed typo; at runtime the two are indistinguishable. Tests also fail when an extension is later adopted upstream, or when one is declared for a property nothing reads. +- **A peer record and simulator coverage check.** `spec_lock.json` now records the producer this parser is developed against — the SPAN simulator, `role: publisher` — with the specification commit and firmware range it pins, and a captured copy of the tree + it publishes is vendored alongside the catalogs. Two sides reading different vocabularies is now a test failure rather than something noticed later, and the anchor asserted between them is the **firmware range**, since the specification says what a + device class may publish while a panel publishes one specific tree. +- **An explicit record of what the producer does not exercise.** Of the 42 `(capability, property)` pairs this adapter reads, the simulator's captured tree declares 41. The exception is `grid/islanding-state`: the simulator models a MID but its tracked + config publishes none, so `grid_state` — corrected in `0.1.0b2` to read `islanding-state` rather than `grid-state` — is the single mapping the producer gives no evidence for. Recorded rather than left implicit, because a passing suite otherwise reads as + coverage it does not have. The entry is rejected once the simulator starts publishing it. -Provenance (byte comparison against a specification checkout) is skipped unless `EBUS_SPEC_DIR` is set, so conformance runs everywhere while the byte check stays opportunistic. Provenance proves the right bytes were copied; it cannot prove they were -understood, which is what conformance is for. +Provenance (byte comparison against a specification or simulator checkout) is skipped unless `EBUS_SPEC_DIR` / `SPAN_SIMULATOR_DIR` are set, so conformance and coverage run everywhere while the byte checks stay opportunistic. Provenance proves the right +bytes were copied; it cannot prove they were understood, which is what the other two are for. + +### Fixed + +- **The conformance check was reading the wrong set of names.** Built from `_PROPERTY_FIELD_MAP` alone, it covered only properties that carry field metadata and silently skipped everything the snapshot mapper reads directly — the MID, `connection` + feeds/fed-by, `info/direction`. `grid_state`, the most recently corrected mapping in this package, was among them. The read set is now derived from the source itself, so it cannot fall behind the code; that immediately surfaced `info/direction` as a + fifteenth undeclared extension. ## [0.1.0b2] - 08/2026 diff --git a/packages/schema-1/spec/fixtures/simulator_tree.json b/packages/schema-1/spec/fixtures/simulator_tree.json new file mode 100644 index 0000000..1c1ddeb --- /dev/null +++ b/packages/schema-1/spec/fixtures/simulator_tree.json @@ -0,0 +1,4924 @@ +{ + "13044bfbcbe5554b8f3dba126bce828f": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Kitchen Outlets (Island)", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464082 + }, + "1bfdc7ecebb0547bbe87a3696cddb0c0": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "SPAN Drive - Driveway", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464091 + }, + "1eeeb748eeaa58edb7e9b7e9dbbdeca7": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Smoke Detectors", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464088 + }, + "2140a7e253ed54e3bc90a959081df615": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Refrigerator", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464085 + }, + "249a2f59782e5f1ab317c4632e79afad": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "SPAN Drive - Garage", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464091 + }, + "3d9d86f303cc50d1827be57d4c667e53": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Bedroom Lights", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464080 + }, + "3eeb0eb1605e5a7eadac41994b7a096c": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Master Bedroom Outlets", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464081 + }, + "43a0521737db516f99f14a9964ea4af0": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Washing Machine", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464086 + }, + "4aeb08c46c2c5905a944166413f2f1ef": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Garbage Disposal", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464087 + }, + "4ce8b30e8d3f5c49b9e0ab0c8caf4832": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Water Heater", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464090 + }, + "4d1deb6acb065746b13207b1358f8ca7": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Dishwasher", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464085 + }, + "516694a326a35cd88600b3520e8a981a": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Pool Pump", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464088 + }, + "6fcb352679ad5bfb8c8a8eab06829b9f": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Solar Inverter", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464091 + }, + "770e2de52c33508a8a9ee8878064b46f": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Master Bedroom Lights", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464079 + }, + "80a4fada833156ab8112f9d50e252b8f": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Kitchen Outlets (Counter)", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464082 + }, + "9429f828509e58d59cb5f0f9f5fee523": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Living Room Lights", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464079 + }, + "948dea7788aa5c959b99df0edfabead2": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Heat Pump", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464089 + }, + "af731c49a6785a4cb2ea5549fb8bce7e": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Main HVAC", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464089 + }, + "afe90839f2725e3e962fb05afa2b6d43": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Chest Freezer", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464086 + }, + "b24483358d29589d8e91d3bf11113269": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Office Outlets", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464083 + }, + "b9fa08f1eaaf5d129bd5c78e1d5d937f": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "kitchen Lights", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464092 + }, + "be7742043a06554aab2a1e38cc776603": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Electric Oven/Range", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464090 + }, + "bess": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Battery", + "nodes": { + "info": { + "name": "info", + "properties": { + "firmware-version": { + "datatype": "string", + "name": "Firmware version" + }, + "model": { + "datatype": "string", + "name": "Model" + }, + "nameplate-capacity": { + "datatype": "float", + "name": "Nameplate capacity", + "unit": "kWh" + }, + "part-number": { + "datatype": "string", + "name": "Part number" + }, + "serial-number": { + "datatype": "string", + "name": "Serial number" + }, + "vendor-name": { + "datatype": "string", + "name": "Vendor name" + } + }, + "type": "energy.ebus.capability.info" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Active power", + "unit": "W" + } + }, + "type": "energy.ebus.capability.meter" + }, + "soc": { + "name": "soc", + "properties": { + "soc": { + "datatype": "float", + "name": "State of charge", + "unit": "%" + }, + "soe": { + "datatype": "float", + "name": "State of energy", + "unit": "kWh" + } + }, + "type": "energy.ebus.capability.soc" + }, + "status": { + "name": "status", + "properties": { + "communication-state": { + "datatype": "enum", + "format": "OK,DEGRADED,LOST,UNKNOWN", + "name": "Communication state" + } + }, + "type": "energy.ebus.capability.status" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.bess", + "version": 1786054464078 + }, + "c058aa11287f50f9b81e5160a0678869": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Bathroom Lights", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464080 + }, + "c339ec7ce7ff521ca7646f9606baff9f": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Guest Room Outlets", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464084 + }, + "d1ff145887a05b839ede89409c27b398": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Garage Outlets", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464083 + }, + "e0ac90e169e6550ea83fe0b1942f1d0e": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Living Room Outlets", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464081 + }, + "e0bc156c85015a609d4132084dfcd6fe": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Microwave", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464086 + }, + "edee3425d50d51ffb022ee999053b2b4": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Laundry Room Outlets", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464084 + }, + "ef972f063451539e8b2ad88e831d87b6": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Electric Dryer", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464089 + }, + "evse": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "SPAN Drive - Garage", + "nodes": { + "config": { + "name": "config", + "properties": { + "max-charge-current": { + "datatype": "integer", + "name": "Commissioned maximum EVSE charge current (installer-configured)", + "unit": "A" + }, + "user-max-charge-current": { + "datatype": "integer", + "name": "User-configured maximum EVSE charge current (ceiling)", + "settable": true, + "unit": "A" + } + }, + "type": "energy.ebus.capability.config" + }, + "info": { + "name": "info", + "properties": { + "firmware-version": { + "datatype": "string", + "name": "Firmware version" + }, + "model": { + "datatype": "string", + "name": "Model" + }, + "part-number": { + "datatype": "string", + "name": "Part number" + }, + "serial-number": { + "datatype": "string", + "name": "Serial number" + }, + "vendor-name": { + "datatype": "string", + "name": "Vendor name" + } + }, + "type": "energy.ebus.capability.info" + }, + "meter": { + "name": "meter", + "properties": { + "advertised-current": { + "datatype": "float", + "name": "Current EVSE is advertising to the EV", + "unit": "A" + } + }, + "type": "energy.ebus.capability.meter" + }, + "status": { + "name": "status", + "properties": { + "status": { + "datatype": "enum", + "format": "AVAILABLE,PREPARING,CHARGING,UNAVAILABLE", + "name": "Status" + } + }, + "type": "energy.ebus.capability.status" + }, + "switch": { + "name": "switch", + "properties": { + "lock-state": { + "datatype": "enum", + "format": "UNLOCKED,LOCKED", + "name": "Lock state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.evse", + "version": 1786054464092 + }, + "evse-2": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "SPAN Drive - Driveway", + "nodes": { + "config": { + "name": "config", + "properties": { + "max-charge-current": { + "datatype": "integer", + "name": "Commissioned maximum EVSE charge current (installer-configured)", + "unit": "A" + }, + "user-max-charge-current": { + "datatype": "integer", + "name": "User-configured maximum EVSE charge current (ceiling)", + "settable": true, + "unit": "A" + } + }, + "type": "energy.ebus.capability.config" + }, + "info": { + "name": "info", + "properties": { + "firmware-version": { + "datatype": "string", + "name": "Firmware version" + }, + "model": { + "datatype": "string", + "name": "Model" + }, + "part-number": { + "datatype": "string", + "name": "Part number" + }, + "serial-number": { + "datatype": "string", + "name": "Serial number" + }, + "vendor-name": { + "datatype": "string", + "name": "Vendor name" + } + }, + "type": "energy.ebus.capability.info" + }, + "meter": { + "name": "meter", + "properties": { + "advertised-current": { + "datatype": "float", + "name": "Current EVSE is advertising to the EV", + "unit": "A" + } + }, + "type": "energy.ebus.capability.meter" + }, + "status": { + "name": "status", + "properties": { + "status": { + "datatype": "enum", + "format": "AVAILABLE,PREPARING,CHARGING,UNAVAILABLE", + "name": "Status" + } + }, + "type": "energy.ebus.capability.status" + }, + "switch": { + "name": "switch", + "properties": { + "lock-state": { + "datatype": "enum", + "format": "UNLOCKED,LOCKED", + "name": "Lock state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.evse", + "version": 1786054464093 + }, + "f515a0f43b6555b1a196fbb62728c24e": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Exterior Lights", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "poles": { + "datatype": "integer", + "format": "1:4:1", + "name": "Number of breaker poles" + }, + "rating": { + "datatype": "integer", + "name": "Circuit breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this circuit" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" + }, + "spaces": { + "datatype": "string", + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" + } + }, + "type": "energy.ebus.capability.info" + }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "switch": { + "name": "switch", + "properties": { + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true + }, + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" + }, + "relay-requester": { + "datatype": "enum", + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.circuit", + "version": 1786054464080 + }, + "lugs-downstream": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Downstream lugs", + "nodes": { + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated up/downstream" + }, + "fed-by-device-id": { + "datatype": "string", + "name": "Homie device-id of the upstream device feeding this lugs" + }, + "fed-by-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the upstream device" + }, + "fed-by-device-type": { + "datatype": "string", + "name": "Homie $type of the upstream device" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this lugs" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "direction": { + "datatype": "enum", + "format": "UPSTREAM,DOWNSTREAM", + "name": "Lugs feed direction: upstream or downstream" + } + }, + "type": "energy.ebus.capability.info" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Active power", + "unit": "W" + }, + "current-a": { + "datatype": "float", + "name": "L1 current", + "unit": "A" + }, + "current-b": { + "datatype": "float", + "name": "L2 current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Exported energy", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Imported energy", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.lugs", + "version": 1786054464093 + }, + "lugs-upstream": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Upstream lugs", + "nodes": { + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated up/downstream" + }, + "fed-by-device-id": { + "datatype": "string", + "name": "Homie device-id of the upstream device feeding this lugs" + }, + "fed-by-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the upstream device" + }, + "fed-by-device-type": { + "datatype": "string", + "name": "Homie $type of the upstream device" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this lugs" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "direction": { + "datatype": "enum", + "format": "UPSTREAM,DOWNSTREAM", + "name": "Lugs feed direction: upstream or downstream" + } + }, + "type": "energy.ebus.capability.info" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Active power", + "unit": "W" + }, + "current-a": { + "datatype": "float", + "name": "L1 current", + "unit": "A" + }, + "current-b": { + "datatype": "float", + "name": "L2 current", + "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Exported energy", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Imported energy", + "unit": "Wh" + } + }, + "type": "energy.ebus.capability.meter" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.lugs", + "version": 1786054464093 + }, + "pv": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Solar", + "nodes": { + "info": { + "name": "info", + "properties": { + "firmware-version": { + "datatype": "string", + "name": "Firmware version" + }, + "model": { + "datatype": "string", + "name": "Model" + }, + "nominal-power": { + "datatype": "float", + "name": "Nominal power", + "unit": "W" + }, + "serial-number": { + "datatype": "string", + "name": "Serial number" + }, + "vendor-name": { + "datatype": "string", + "name": "Vendor name" + } + }, + "type": "energy.ebus.capability.info" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.pv", + "version": 1786054464094 + }, + "sim-40t-001": { + "children": [ + "bess", + "770e2de52c33508a8a9ee8878064b46f", + "9429f828509e58d59cb5f0f9f5fee523", + "3d9d86f303cc50d1827be57d4c667e53", + "c058aa11287f50f9b81e5160a0678869", + "f515a0f43b6555b1a196fbb62728c24e", + "3eeb0eb1605e5a7eadac41994b7a096c", + "e0ac90e169e6550ea83fe0b1942f1d0e", + "80a4fada833156ab8112f9d50e252b8f", + "13044bfbcbe5554b8f3dba126bce828f", + "b24483358d29589d8e91d3bf11113269", + "d1ff145887a05b839ede89409c27b398", + "edee3425d50d51ffb022ee999053b2b4", + "c339ec7ce7ff521ca7646f9606baff9f", + "2140a7e253ed54e3bc90a959081df615", + "4d1deb6acb065746b13207b1358f8ca7", + "43a0521737db516f99f14a9964ea4af0", + "e0bc156c85015a609d4132084dfcd6fe", + "afe90839f2725e3e962fb05afa2b6d43", + "4aeb08c46c2c5905a944166413f2f1ef", + "516694a326a35cd88600b3520e8a981a", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7", + "ef972f063451539e8b2ad88e831d87b6", + "af731c49a6785a4cb2ea5549fb8bce7e", + "948dea7788aa5c959b99df0edfabead2", + "be7742043a06554aab2a1e38cc776603", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832", + "249a2f59782e5f1ab317c4632e79afad", + "1bfdc7ecebb0547bbe87a3696cddb0c0", + "6fcb352679ad5bfb8c8a8eab06829b9f", + "b9fa08f1eaaf5d129bd5c78e1d5d937f", + "evse", + "evse-2", + "lugs-upstream", + "lugs-downstream", + "pv" + ], + "extensions": [], + "homie": "5.0", + "name": "Span Panel", + "nodes": { + "breaker": { + "name": "breaker", + "properties": { + "rating": { + "datatype": "integer", + "name": "Main breaker rating", + "unit": "A" + } + }, + "type": "energy.ebus.capability.breaker" + }, + "door": { + "name": "door", + "properties": { + "state": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Door state" + } + }, + "type": "energy.ebus.capability.door" + }, + "info": { + "name": "info", + "properties": { + "data-model-version": { + "datatype": "string", + "name": "eBus data-model version (parent/child schema discriminator)" + }, + "firmware-version": { + "datatype": "string", + "name": "Firmware version" + }, + "hardware-version": { + "datatype": "string", + "name": "Hardware version" + }, + "model": { + "datatype": "enum", + "format": "MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48", + "name": "Model" + }, + "serial-number": { + "datatype": "string", + "name": "Serial number" + }, + "vendor-name": { + "datatype": "string", + "name": "Vendor name" + } + }, + "type": "energy.ebus.capability.info" + }, + "meter": { + "name": "meter", + "properties": { + "voltage-a": { + "datatype": "float", + "name": "L1 voltage", + "unit": "V" + }, + "voltage-b": { + "datatype": "float", + "name": "L2 voltage", + "unit": "V" + } + }, + "type": "energy.ebus.capability.meter" + }, + "pcs": { + "name": "pcs", + "properties": { + "active": { + "datatype": "boolean", + "name": "PCS system actively controlling one (or more) loads" + }, + "binding-constraint": { + "datatype": "enum", + "format": "FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN", + "name": "Which constraint class currently sets the import limit" + }, + "enabled": { + "datatype": "boolean", + "name": "PCS system enabled" + }, + "feed-import-limit": { + "datatype": "float", + "name": "Limit of maximum power feeding the distribution enclosure", + "unit": "A" + }, + "feed-import-limit-active": { + "datatype": "boolean", + "name": "Is feed-import-limit currently being enforced?" + }, + "feed-import-limit-enablement": { + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", + "name": "Enablement status of the feed-import-limit" + }, + "import-limit": { + "datatype": "float", + "name": "The power import limit currently being managed to", + "unit": "A" + }, + "off-grid-import-limit": { + "datatype": "float", + "name": "Off-Grid limit maximum import power", + "unit": "A" + }, + "off-grid-import-limit-active": { + "datatype": "boolean", + "name": "Is off-grid-import-limit currently being enforced?" + }, + "off-grid-import-limit-enablement": { + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", + "name": "Enablement status of the off-grid-import-limit" + }, + "operator-import-limit": { + "datatype": "float", + "name": "Operator-imposed maximum import limit", + "unit": "A" + }, + "operator-import-limit-active": { + "datatype": "boolean", + "name": "Is operator-import-limit currently being enforced?" + }, + "operator-import-limit-enablement": { + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", + "name": "Enablement status of the operator-import-limit" + }, + "requested-import-limit": { + "datatype": "float", + "name": "Requested limit maximum import power", + "unit": "A" + }, + "requested-import-limit-active": { + "datatype": "boolean", + "name": "Is requested-import-limit currently being enforced?" + }, + "requested-import-limit-enablement": { + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", + "name": "Enablement status of the requested-import-limit" + } + }, + "type": "energy.ebus.capability.pcs" + }, + "power-flows": { + "name": "power-flows", + "properties": { + "battery": { + "datatype": "float", + "name": "Battery/BESS power flow", + "unit": "W" + }, + "grid": { + "datatype": "float", + "name": "Grid power flow", + "unit": "W" + }, + "pv": { + "datatype": "float", + "name": "PV power flow", + "unit": "W" + }, + "site": { + "datatype": "float", + "name": "Site power flow", + "unit": "W" + } + }, + "type": "energy.ebus.capability.power-flows" + }, + "shed": { + "name": "shed", + "properties": { + "asserted-islanding-state": { + "datatype": "enum", + "format": "NONE,ON_GRID,OFF_GRID", + "name": "Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)", + "settable": true + }, + "policy": { + "datatype": "json", + "format": "{\"$id\":\"soc-priority.v1\",\"type\":\"object\",\"required\":[\"algorithm\",\"parameters\"],\"additionalProperties\":false,\"properties\":{\"algorithm\":{\"const\":\"soc-priority.v1\"},\"parameters\":{\"type\":\"object\",\"required\":[\"soc-threshold-shed\",\"soc-threshold-release\"],\"additionalProperties\":false,\"properties\":{\"soc-threshold-shed\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":100,\"description\":\"SoC percent below which SOC_THRESHOLD circuits shed\"},\"soc-threshold-release\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":100,\"description\":\"SoC percent above which shed SOC_THRESHOLD circuits restore\"}}}}}", + "name": "Shed policy (algorithm and parameters)" + } + }, + "type": "energy.ebus.capability.shed" + }, + "shed-forecast": { + "name": "shed-forecast", + "properties": { + "confidence": { + "datatype": "enum", + "format": "LOW,MEDIUM,HIGH", + "name": "Confidence of the shed-forecast estimate" + }, + "full-charge-time-to-priority-shed": { + "datatype": "integer", + "name": "Estimated time to next priority shed assuming BESS starts at full charge", + "unit": "min" + }, + "full-charge-total-time-remaining": { + "datatype": "integer", + "name": "Estimated total time assuming BESS starts at full charge", + "unit": "min" + }, + "time-to-priority-shed": { + "datatype": "integer", + "name": "Estimated time before the next priority tier is shed", + "unit": "min" + }, + "total-time-remaining": { + "datatype": "integer", + "name": "Estimated total time before all sheddable circuits are shed (off-grid runtime)", + "unit": "min" + } + }, + "type": "energy.ebus.capability.shed-forecast" + }, + "status": { + "name": "status", + "properties": { + "cloud-connection": { + "datatype": "enum", + "format": "UNKNOWN,UNCONNECTED,CONNECTED", + "name": "Device connected to vendor cloud?" + }, + "ethernet": { + "datatype": "boolean", + "name": "Is Ethernet network interface operational?" + }, + "postal-code": { + "datatype": "string", + "name": "Postal (Zip) code" + }, + "relay": { + "datatype": "enum", + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Main relay" + }, + "time-zone": { + "datatype": "string", + "name": "Time zone" + }, + "wifi": { + "datatype": "boolean", + "name": "Is Wi-Fi network interface operational?" + }, + "wifi-ssid": { + "datatype": "string", + "name": "SSID to which Wi-Fi network interface is connected" + } + }, + "type": "energy.ebus.capability.status" + } + }, + "type": "energy.ebus.device.distribution-enclosure", + "version": 1786054464078 + } +} diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json index 19e0a58..0a44ed1 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -10,6 +10,15 @@ "synced_commit": "4254526b0a8c11cab0d40fd700b1fc295c0479c6", "synced_date": "2026-08-06", "framework": "0.7", + "peer": { + "repo": "https://github.com/SpanPanel/simulator", + "ref": "feat/ebus-parent-child-schema", + "role": "publisher", + "commit": "b6f638850cf75acd14c181517b08ca7be7f866c1", + "synced_commit": "4254526b0a8c11cab0d40fd700b1fc295c0479c6", + "firmware_range": "r202633+", + "fixture": "tests/conformance/fixtures/golden_tree.json" + }, "implements": { "capabilities": { "breaker": "0.1", diff --git a/tests/test_schema_one_conformance.py b/tests/test_schema_one_conformance.py index 5b9459d..70b50db 100644 --- a/tests/test_schema_one_conformance.py +++ b/tests/test_schema_one_conformance.py @@ -1,4 +1,5 @@ -"""Conformance checks — is every name this adapter reads one the eBus spec defines? +"""Conformance checks — is every name this adapter reads one the eBus spec defines, +and does the producer we test against actually publish it? The consumer counterpart to `test_schema_provenance.py`, which does the same job for the flat adapter against SPAN's own schema document. This one runs against @@ -14,19 +15,24 @@ capability key outright in favour of `soc`, with no alias. A consumer hardcoding `battery` would have gone quiet rather than broken. -Two checks with different reach, deliberately: +Three checks with different reach, deliberately: -- **Conformance** (below) compares this adapter against the vendored catalogs and - always runs, so CI needs no network and no specification checkout. -- **Provenance** (the last test) compares the vendored catalogs against the - specification itself, and skips unless `EBUS_SPEC_DIR` points at a checkout. +- **Conformance** — this adapter against the vendored catalogs. Always runs, so + CI needs no network and no sibling checkout. +- **Coverage** — this adapter against a captured tree from the SPAN simulator, + the producer our development is done against. Always runs, from a vendored copy. +- **Provenance** — the vendored copies against their sources. Skipped unless + `EBUS_SPEC_DIR` / `SPAN_SIMULATOR_DIR` point at checkouts. Provenance proves we copied the right bytes; it cannot prove we understood them. -Conformance is where the understanding gets checked. +The first two are where the understanding gets checked, which is why they are the +ones that must run everywhere. """ from __future__ import annotations +import ast +import importlib import json import os from pathlib import Path @@ -37,10 +43,17 @@ from span_panel_api_schema_1 import const from span_panel_api_schema_1.field_metadata import _PROPERTY_FIELD_MAP +# Defined in panel.py rather than const.py, which is itself the point: the read +# set has to be derived from the modules that do the reading, not from one +# module that happens to hold most of the vocabulary. +from span_panel_api_schema_1.panel import PROP_ISLANDING_STATE + _SPEC = Path(__file__).parent.parent / "packages" / "schema-1" / "spec" _CATALOGS = _SPEC / "catalogs" _DEVICE_TYPES = _SPEC / "registries" / "device-types.md" -_LOCK = Path(const.__file__).parent / "spec_lock.json" +_SIMULATOR_TREE = _SPEC / "fixtures" / "simulator_tree.json" +_SOURCE = Path(const.__file__).parent +_LOCK = _SOURCE / "spec_lock.json" def _lock() -> dict[str, object]: @@ -49,6 +62,12 @@ def _lock() -> dict[str, object]: return loaded +def _peer() -> dict[str, str]: + peer = _lock()["peer"] + assert isinstance(peer, dict) + return {str(key): str(value) for key, value in peer.items()} + + def _catalog(node: str) -> dict[str, object]: with (_CATALOGS / f"{node}.json").open() as handle: loaded: dict[str, object] = json.load(handle) @@ -61,6 +80,57 @@ def _catalog_properties(node: str) -> set[str]: return set(properties) +def _read_pairs() -> set[tuple[str, str]]: + """Every ``(capability node, property)`` this adapter addresses. + + Derived, not listed, so it cannot drift from the code the way a hand-kept + inventory does — the same reason `_derive_required_members` reads the + protocol rather than restating it. + + Two sources, because the adapter addresses properties two ways. + `_PROPERTY_FIELD_MAP` is the metadata contract, and is already declarative. + The snapshot mapper instead calls readers like ``text(mid, NODE_GRID, + PROP_ISLANDING_STATE)``, which no table records — and building this from the + metadata map alone quietly omitted every one of them, including the MID + reads, when this check was first written. + + Constants are resolved from the module that uses them rather than from + `const`, because not all of them live there. + """ + pairs = {(node, property_id) for _, node, property_id, _ in _PROPERTY_FIELD_MAP} + + for path in sorted(_SOURCE.glob("*.py")): + if path.stem == "__init__": + continue + module = importlib.import_module(f"span_panel_api_schema_1.{path.stem}") + for call in (n for n in ast.walk(ast.parse(path.read_text(encoding="utf-8"))) if isinstance(n, ast.Call)): + names = [arg.id for arg in call.args if isinstance(arg, ast.Name)] + for node_name in (name for name in names if name.startswith("NODE_")): + for property_name in (name for name in names if name.startswith("PROP_")): + node = getattr(module, node_name, None) + property_id = getattr(module, property_name, None) + if isinstance(node, str) and isinstance(property_id, str): + pairs.add((node, property_id)) + return pairs + + +def _simulator_declared() -> set[tuple[str, str]]: + """Every ``(node, property)`` the captured simulator tree declares anywhere. + + Flattened across devices rather than kept per device type, matching the + granularity of the catalogs: a capability's property set is the same + wherever that capability appears. + """ + with _SIMULATOR_TREE.open() as handle: + tree: dict[str, dict[str, object]] = json.load(handle) + return { + (node_id, property_id) + for device in tree.values() + for node_id, node in (device.get("nodes") or {}).items() # type: ignore[union-attr] + for property_id in (node.get("properties") or {}) + } + + # Properties this adapter reads that no catalog defines. # # These are legal: the specification lets a publisher emit properties it has @@ -81,6 +151,7 @@ def _catalog_properties(node: str) -> set[str]: (const.NODE_METER, "advertised-current"): "EVSE pilot-advertised current", (const.NODE_INFO, "name"): "circuit label; Homie's $name is the device name, not the circuit's", (const.NODE_INFO, "spaces"): "breaker spaces occupied, a load-centre concept the catalog has no room for", + (const.NODE_INFO, "direction"): "which of the two identically-typed lugs devices is upstream", (const.NODE_INFO, "nominal-power"): ( "PV AC power rating in W. Deliberately not the catalog's nameplate-capacity, " "which is stored energy with an abstract unit — a different quantity with a confusable name." @@ -89,6 +160,20 @@ def _catalog_properties(node: str) -> set[str]: } +# Properties this adapter reads that the captured simulator tree never declares. +# +# Not defects on either side, but the precise list of what our development +# producer does not exercise — which is exactly the part of the parser that gets +# no evidence from testing against it. +_NOT_EXERCISED_BY_SIMULATOR: dict[tuple[str, str], str] = { + (const.NODE_GRID, PROP_ISLANDING_STATE): ( + "the simulator models a MID (wire/profiles/mid.json) but its tracked config publishes none, " + "so grid_state — corrected 2026-08-06 to read islanding-state rather than grid-state — is the " + "one mapping the producer gives no evidence for" + ), +} + + # --------------------------------------------------------------------------- # The lockfile describes what is actually vendored # --------------------------------------------------------------------------- @@ -114,26 +199,25 @@ def test_every_pinned_capability_is_vendored_at_the_pinned_version() -> None: def test_every_capability_node_this_adapter_reads_has_a_vendored_catalog() -> None: """Adding a NODE_* to const.py without vendoring its catalog would leave that node's properties unchecked while looking checked.""" - read = {value for name, value in vars(const).items() if name.startswith("NODE_") and isinstance(value, str)} + read = {node for node, _ in _read_pairs()} vendored = {path.stem for path in _CATALOGS.glob("*.json")} assert read <= vendored, f"capability nodes read but not vendored: {sorted(read - vendored)}" # --------------------------------------------------------------------------- -# The core assertion — every name resolves, or is a declared extension +# Conformance — every name resolves, or is a declared extension # --------------------------------------------------------------------------- -def test_every_mapped_property_is_catalogued_or_a_declared_extension() -> None: - """`_PROPERTY_FIELD_MAP` is the adapter's statement of what it reads. Every - row must be a property the specification defines, or one this file declares - SPAN publishes on its own account.""" - undeclared = [ - f"{device_type} {node}/{property_id} -> {field_path}" - for device_type, node, property_id, field_path in _PROPERTY_FIELD_MAP +def test_every_property_read_is_catalogued_or_a_declared_extension() -> None: + """The core assertion, over everything the adapter addresses rather than only + what carries metadata.""" + undeclared = sorted( + f"{node}/{property_id}" + for node, property_id in _read_pairs() if property_id not in _catalog_properties(node) and (node, property_id) not in _SPAN_EXTENSIONS - ] + ) assert not undeclared, ( "properties read by this adapter that no catalog defines and no extension declares:\n " @@ -143,6 +227,19 @@ def test_every_mapped_property_is_catalogued_or_a_declared_extension() -> None: ) +def test_the_read_set_reaches_past_the_metadata_map() -> None: + """`_read_pairs` exists because the metadata map is not the whole read set. + + Pinned because the omission is invisible: a check built on the map alone + passes cleanly while never looking at the MID, which is where `grid_state` + comes from. + """ + mapped = {(node, property_id) for _, node, property_id, _ in _PROPERTY_FIELD_MAP} + + assert (const.NODE_GRID, PROP_ISLANDING_STATE) not in mapped, "the MID now carries metadata; simplify this" + assert (const.NODE_GRID, PROP_ISLANDING_STATE) in _read_pairs(), "the MID read is no longer being discovered" + + def test_no_declared_extension_has_been_adopted_by_the_specification() -> None: """The reverse direction. When upstream adopts a name we carried as an extension, the entry becomes wrong — and silently so, because everything @@ -165,8 +262,7 @@ def test_no_declared_extension_has_been_adopted_by_the_specification() -> None: def test_no_extension_is_declared_for_a_property_nothing_reads() -> None: """An allowlist that outlives its use quietly grants permission for names the adapter no longer has, which is how allowlists rot.""" - read = {(node, property_id) for _, node, property_id, _ in _PROPERTY_FIELD_MAP} - unused = sorted(pair for pair in _SPAN_EXTENSIONS if pair not in read) + unused = sorted(pair for pair in _SPAN_EXTENSIONS if pair not in _read_pairs()) assert not unused, f"extensions declared for properties nothing reads: {unused}" @@ -207,7 +303,7 @@ def test_an_abstract_unit_is_never_taken_from_the_catalog() -> None: assert abstract, "no catalog property carries an abstract unit; this test no longer guards anything" assert (const.NODE_SOC, "soe") in abstract, "soc/soe is the one this adapter reads; the catalog no longer marks it" - metadata_source = (Path(const.__file__).parent / "field_metadata.py").read_text(encoding="utf-8") + metadata_source = (_SOURCE / "field_metadata.py").read_text(encoding="utf-8") assert "spec_lock" not in metadata_source and "catalogs" not in metadata_source, ( "field_metadata.py now references the vendored spec. Units must come from each device's " "$description; the catalog is the superset across all hardware and carries abstract units." @@ -215,23 +311,85 @@ def test_an_abstract_unit_is_never_taken_from_the_catalog() -> None: # --------------------------------------------------------------------------- -# Provenance — opportunistic, because it needs a checkout +# Coverage — does the producer we develop against exercise what we read? +# --------------------------------------------------------------------------- + + +def test_the_peer_is_pinned_to_the_same_specification_commit() -> None: + """Publisher and consumer must be reading the same vocabulary. + + Checked against the recorded peer rather than a live checkout so it runs + everywhere. Its real job is to make bumping our own pin without looking at + the other side impossible to do quietly. + """ + assert _peer()["synced_commit"] == _lock()["synced_commit"], ( + "this adapter and the simulator it is developed against are pinned to different " + "specification commits; re-vendor both, or record why they may differ." + ) + + +def test_the_peer_targets_the_same_firmware() -> None: + """The firmware range is the anchor the two sides actually share — the spec + says what a device class *may* publish, while a panel publishes one tree.""" + firmware = _lock()["firmware"] + assert isinstance(firmware, dict) + + assert _peer()["firmware_range"] == firmware["range"] + + +def test_every_property_read_is_exercised_by_the_simulator() -> None: + """What the producer never publishes, testing against it never proves. + + An entry in `_NOT_EXERCISED_BY_SIMULATOR` is not a defect on either side; it + is a precise statement of where this parser has no evidence, which is worth + knowing before trusting a passing suite. + """ + declared = _simulator_declared() + unexercised = sorted( + f"{node}/{property_id}" + for node, property_id in _read_pairs() + if (node, property_id) not in declared and (node, property_id) not in _NOT_EXERCISED_BY_SIMULATOR + ) + + assert not unexercised, ( + "properties this adapter reads that the captured simulator tree never declares:\n " + + "\n ".join(unexercised) + + "\n\nEither the simulator should publish them, or record why it does not in " + "_NOT_EXERCISED_BY_SIMULATOR." + ) + + +def test_nothing_is_recorded_as_unexercised_once_the_simulator_publishes_it() -> None: + """When the producer starts covering a gap, the entry stops being true. Left + in place it would go on excusing a property that is now testable.""" + declared = _simulator_declared() + now_covered = sorted( + f"{node}/{property_id}" for node, property_id in _NOT_EXERCISED_BY_SIMULATOR if (node, property_id) in declared + ) + + assert not now_covered, ( + "the simulator now declares these; drop them from _NOT_EXERCISED_BY_SIMULATOR " + "and let the coverage check hold them:\n " + "\n ".join(now_covered) + ) + + +# --------------------------------------------------------------------------- +# Provenance — opportunistic, because it needs checkouts # --------------------------------------------------------------------------- def test_vendored_catalogs_are_byte_identical_to_the_specification() -> None: """Byte comparison against the specification at `synced_commit`. - Skipped rather than failed without a checkout: the conformance checks above - are the ones that must run everywhere, and making all of them depend on a - second repository would mean they stop running. + Skipped rather than failed without a checkout: the checks above are the ones + that must run everywhere, and making them depend on a second repository would + mean they stop running. """ spec_dir = os.environ.get("EBUS_SPEC_DIR") if not spec_dir: pytest.skip("set EBUS_SPEC_DIR to a specification checkout to verify vendored bytes") spec = Path(spec_dir) - lock = _lock() differing = [ path.name for path in sorted(_CATALOGS.glob("*.json")) @@ -239,6 +397,38 @@ def test_vendored_catalogs_are_byte_identical_to_the_specification() -> None: ] assert not differing, ( - f"vendored catalogs differ from {spec_dir} (lockfile pins {lock['synced_commit']}): {differing}. " + f"vendored catalogs differ from {spec_dir} (lockfile pins {_lock()['synced_commit']}): {differing}. " "Check the checkout is at synced_commit before assuming the copies are wrong." ) + + +def test_the_vendored_simulator_tree_matches_the_simulator() -> None: + """The captured tree against the simulator that produced it.""" + sim_dir = os.environ.get("SPAN_SIMULATOR_DIR") + if not sim_dir: + pytest.skip("set SPAN_SIMULATOR_DIR to a simulator checkout to verify the captured tree") + + peer = _peer() + source = Path(sim_dir) / peer["fixture"] + assert source.exists(), f"{source} is missing; is {sim_dir} on {peer['ref']}?" + + assert source.read_bytes() == _SIMULATOR_TREE.read_bytes(), ( + f"the captured tree differs from {source}. Re-capture it and update peer.commit " f"(recorded: {peer['commit']})." + ) + + +def test_the_peer_record_matches_the_simulator_lockfile() -> None: + """What we believe the producer pins, against what it actually pins.""" + sim_dir = os.environ.get("SPAN_SIMULATOR_DIR") + if not sim_dir: + pytest.skip("set SPAN_SIMULATOR_DIR to a simulator checkout to verify the peer record") + + with (Path(sim_dir) / ".ebus-spec.json").open() as handle: + theirs = json.load(handle) + peer = _peer() + + assert theirs["role"] == peer["role"], "the peer is not publishing; this pairing is not what it claims" + assert theirs["synced_commit"] == peer["synced_commit"], ( + f"the simulator now pins {theirs['synced_commit']}, we recorded {peer['synced_commit']}. " + "Re-vendor and update both, or the two sides are reading different vocabularies." + ) From 9ef8b19b104b7432d6da88d9d5be34f7894b1ba4 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:25:19 -0700 Subject: [PATCH 039/115] chore(schema_1): point the peer record at panelbench The parent/child simulator now lives in its own repository rather than on a branch of the flat one. The two publish incompatible schemas and there is no hot-loader notion on the producer side, so a branch that could never merge was a fork wearing a branch's clothes. Only the coordinates change. The commit is the same object, pushed with its history intact, and the captured tree is byte-identical -- so nothing the conformance or coverage checks assert is affected. This is exactly the indifference to repository shape that made vendoring the right choice over a submodule: a submodule would have needed re-pointing at a new remote, while this is two strings. The flat simulator keeps its name and its add-on repository URL, so no installed add-on is disturbed. --- .../schema-1/src/span_panel_api_schema_1/spec_lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json index 0a44ed1..f0e9c6b 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -11,8 +11,8 @@ "synced_date": "2026-08-06", "framework": "0.7", "peer": { - "repo": "https://github.com/SpanPanel/simulator", - "ref": "feat/ebus-parent-child-schema", + "repo": "https://github.com/SpanPanel/panelbench", + "ref": "main", "role": "publisher", "commit": "b6f638850cf75acd14c181517b08ca7be7f866c1", "synced_commit": "4254526b0a8c11cab0d40fd700b1fc295c0479c6", @@ -45,5 +45,5 @@ "device-types": "0.5" } }, - "notes": "role=consumer: span-panel-api-schema-1 parses the Homie 5 distribution-enclosure tree that SPAN firmware r202633+ publishes, and is hot-loaded by span-panel-api through the span_panel_api.schema_adapters entry-point group. It is the consumer counterpart to SpanPanel/simulator (role=publisher), which is pinned to the same synced_commit; the shared anchor between them is the firmware range above, not this commit, because the spec says what a device class MAY publish while a panel publishes one specific tree. PROVENANCE: packages/schema-1/spec/catalogs/*.json are byte copies of the specification's capabilities/ at synced_commit, and spec/registries/device-types.md is a byte copy of that registry. They are verified by byte comparison when a specification checkout is available (EBUS_SPEC_DIR); the comparison skips when none is, so the conformance check below always runs while the provenance check is opportunistic. Never hand-edit anything under spec/ -- an edit makes the byte comparison meaningless. WHAT IS VENDORED AND WHY SO LITTLE: only the 13 capability catalogs this adapter addresses, because a consumer needs the vocabulary it reads and nothing else. Datatypes, units and formats are deliberately NOT taken from these catalogs at runtime: the adapter reads them from each device's $description, because the same capability exposes different properties on different device classes (meter is voltage on the panel, power and energy on a circuit, both currents on lugs) and the catalog is the superset across all hardware rather than a statement about this panel. The vendored copies exist to be checked against, not to be parsed in production. ABSTRACT UNITS: four catalog properties carry unit: energy, a dimension rather than a unit (conventions/property-json.md 0.2). Being description-driven makes this adapter correct here by construction, and a test asserts it rather than leaving it to luck. EXTENSIONS: SPAN publishes properties no catalog defines -- per-phase meter readings, panel status links, circuit spaces. Those are legal under the specification and are enumerated as an explicit allowlist in tests/test_schema_one_conformance.py, so a name that is absent from the catalog has to be declared deliberately rather than assumed. PINNING RULE: pin what this adapter actually reads AND that exists in the current spec. pv/evse/mid/lugs have no standalone versioned device model upstream and are covered transitively as child device_types of distribution-enclosure 0.12, so they are not separately pinned." + "notes": "role=consumer: span-panel-api-schema-1 parses the Homie 5 distribution-enclosure tree that SPAN firmware r202633+ publishes, and is hot-loaded by span-panel-api through the span_panel_api.schema_adapters entry-point group. It is the consumer counterpart to SpanPanel/panelbench (role=publisher), which is pinned to the same synced_commit; the shared anchor between them is the firmware range above, not this commit, because the spec says what a device class MAY publish while a panel publishes one specific tree. PROVENANCE: packages/schema-1/spec/catalogs/*.json are byte copies of the specification's capabilities/ at synced_commit, and spec/registries/device-types.md is a byte copy of that registry. They are verified by byte comparison when a specification checkout is available (EBUS_SPEC_DIR); the comparison skips when none is, so the conformance check below always runs while the provenance check is opportunistic. Never hand-edit anything under spec/ -- an edit makes the byte comparison meaningless. WHAT IS VENDORED AND WHY SO LITTLE: only the 13 capability catalogs this adapter addresses, because a consumer needs the vocabulary it reads and nothing else. Datatypes, units and formats are deliberately NOT taken from these catalogs at runtime: the adapter reads them from each device's $description, because the same capability exposes different properties on different device classes (meter is voltage on the panel, power and energy on a circuit, both currents on lugs) and the catalog is the superset across all hardware rather than a statement about this panel. The vendored copies exist to be checked against, not to be parsed in production. ABSTRACT UNITS: four catalog properties carry unit: energy, a dimension rather than a unit (conventions/property-json.md 0.2). Being description-driven makes this adapter correct here by construction, and a test asserts it rather than leaving it to luck. EXTENSIONS: SPAN publishes properties no catalog defines -- per-phase meter readings, panel status links, circuit spaces. Those are legal under the specification and are enumerated as an explicit allowlist in tests/test_schema_one_conformance.py, so a name that is absent from the catalog has to be declared deliberately rather than assumed. PINNING RULE: pin what this adapter actually reads AND that exists in the current spec. pv/evse/mid/lugs have no standalone versioned device model upstream and are covered transitively as child device_types of distribution-enclosure 0.12, so they are not separately pinned." } From aaca001eaf1a109f5f784544bf6637adaa9128b2 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:04:29 -0700 Subject: [PATCH 040/115] feat(schema_1): drive the parser from what the producer actually publishes Every other test in this package runs on a fixture captured off the upstream generic eBus panel simulator. That exercises the mapper, but a generic panel cannot produce SPAN's own vocabulary -- the extensions and divergences are exactly what it lacks -- so the parser had never been run against SPAN's publisher at all. simulator_wire.json is a capture from panelbench: descriptions, $state and 494 property values across 37 devices, fed in sorted topic order because that is how a retained store replays, not how a tree is walked. The parser reaches ready on it, sizes the panel from MAIN_40 and parses all 30 circuits. Values are not asserted. noise_factor perturbs power and current and the clock advances, so a pinned wattage would fail on every recapture for a reason nobody could act on. Structure is asserted; the wire fixture is likewise compared on shape rather than bytes, unlike the tree fixture which is deterministic. Two producer-side gaps are pinned as expectations rather than asserted away. grid_state stays None because nothing instantiates a MID. And every DER -- BESS, PV, both EVSEs -- declares info/model in its $description and never publishes a value; PV declares five info properties and publishes one. That breaks the single standing obligation eBus places on a publisher, to declare accurately what it publishes, and it is structurally invisible to a conformance checker: comparing declarations against catalogs cannot see a declaration nothing fulfils. Only a capture carrying values can, and it found this on its first run. peer.fixture becomes peer.fixtures, keyed by kind, and the provenance check verifies both -- bytes for the tree, shape for the wire. --- packages/schema-1/CHANGELOG.md | 13 +- .../spec/fixtures/simulator_wire.json | 644 ++++++++++++++++++ .../span_panel_api_schema_1/spec_lock.json | 9 +- tests/test_schema_one_against_simulator.py | 161 +++++ tests/test_schema_one_conformance.py | 75 +- 5 files changed, 879 insertions(+), 23 deletions(-) create mode 100644 packages/schema-1/spec/fixtures/simulator_wire.json create mode 100644 tests/test_schema_one_against_simulator.py diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index 17f825c..70278a5 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -29,9 +29,16 @@ number. A release here means this parser changed, never that the panel did. - **An explicit record of what the producer does not exercise.** Of the 42 `(capability, property)` pairs this adapter reads, the simulator's captured tree declares 41. The exception is `grid/islanding-state`: the simulator models a MID but its tracked config publishes none, so `grid_state` — corrected in `0.1.0b2` to read `islanding-state` rather than `grid-state` — is the single mapping the producer gives no evidence for. Recorded rather than left implicit, because a passing suite otherwise reads as coverage it does not have. The entry is rejected once the simulator starts publishing it. - -Provenance (byte comparison against a specification or simulator checkout) is skipped unless `EBUS_SPEC_DIR` / `SPAN_SIMULATOR_DIR` are set, so conformance and coverage run everywhere while the byte checks stay opportunistic. Provenance proves the right -bytes were copied; it cannot prove they were understood, which is what the other two are for. +- **The parser is now driven end to end from what the producer actually publishes.** Every other test in this package runs on a fixture captured off the upstream _generic_ eBus panel simulator, which by construction never carries SPAN's own vocabulary. + `spec/fixtures/simulator_wire.json` is a capture from SPAN's publisher instead — descriptions, `$state` and all 494 property values across 37 devices — fed in sorted topic order, the way a retained store replays it rather than the way a tree is walked. + The parser reaches ready on it, sizes the panel from `MAIN_40`, and parses all 30 circuits. Values are deliberately not asserted: the producer's config carries `noise_factor` and its clock advances, so pinning a wattage would fail on every recapture for + a reason nobody could act on. +- **Two producer-side gaps are pinned rather than left to be noticed.** `grid_state` stays `None` because nothing instantiates a MID, and every DER — BESS, PV and both EVSEs — declares `info/model` in its `$description` and never publishes a value (PV + declares five `info` properties and publishes one). The second breaks the single standing obligation eBus places on a publisher, to declare accurately what it publishes, and is invisible to a conformance checker: comparing declarations against catalogs + cannot see a declaration nothing fulfils. Only a capture carrying values can, which is the argument for this fixture existing. Both are asserted as current expectations, so closing either fails the test that describes it. + +Provenance (byte comparison against a specification or simulator checkout) is skipped unless `EBUS_SPEC_DIR` / `SPAN_SIMULATOR_DIR` are set, so conformance and coverage run everywhere while the byte checks stay opportunistic. The wire capture is compared +on shape rather than bytes for the same reason its values are not asserted. Provenance proves the right bytes were copied; it cannot prove they were understood, which is what the other two are for. ### Fixed diff --git a/packages/schema-1/spec/fixtures/simulator_wire.json b/packages/schema-1/spec/fixtures/simulator_wire.json new file mode 100644 index 0000000..3d70790 --- /dev/null +++ b/packages/schema-1/spec/fixtures/simulator_wire.json @@ -0,0 +1,644 @@ +{ + "13044bfbcbe5554b8f3dba126bce828f": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Island)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Kitchen Outlets (Island)", + "info/spaces": "10", + "load-shed/priority": "NEVER", + "meter/active-power": "-320.76944740178845", + "meter/current": "2.673078728348237", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "9", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "1bfdc7ecebb0547bbe87a3696cddb0c0": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "info/name": "SPAN Drive - Driveway", + "info/spaces": "35,37", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "0.0", + "meter/current": "0.0", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "28", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "1eeeb748eeaa58edb7e9b7e9dbbdeca7": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Smoke Detectors\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Smoke Detectors", + "info/spaces": "40", + "load-shed/priority": "NEVER", + "meter/active-power": "-4.999832443194815", + "meter/current": "0.04166527035995679", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "21", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "2140a7e253ed54e3bc90a959081df615": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Refrigerator\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Refrigerator", + "info/spaces": "15", + "load-shed/priority": "NEVER", + "meter/active-power": "-108.45244558916716", + "meter/current": "0.9037703799097263", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "false", + "pcs/priority": "14", + "switch/relay": "CLOSED", + "switch/relay-controllable": "false", + "switch/relay-requester": "UNKNOWN" + }, + "249a2f59782e5f1ab317c4632e79afad": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "info/name": "SPAN Drive - Garage", + "info/spaces": "32,34", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "0.0", + "meter/current": "0.0", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "27", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "3d9d86f303cc50d1827be57d4c667e53": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Bedroom Lights", + "info/spaces": "4", + "load-shed/priority": "NEVER", + "meter/active-power": "-30.007200655746775", + "meter/current": "0.2500600054645565", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "3", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "3eeb0eb1605e5a7eadac41994b7a096c": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Master Bedroom Outlets", + "info/spaces": "7", + "load-shed/priority": "NEVER", + "meter/active-power": "-153.54536453511025", + "meter/current": "1.279544704459252", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "6", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "43a0521737db516f99f14a9964ea4af0": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Washing Machine\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Washing Machine", + "info/spaces": "17", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-934.9373635553584", + "meter/current": "7.791144696294653", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "16", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "4aeb08c46c2c5905a944166413f2f1ef": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garbage Disposal\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Garbage Disposal", + "info/spaces": "21", + "load-shed/priority": "NEVER", + "meter/active-power": "0.0", + "meter/current": "0.0", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "19", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "4ce8b30e8d3f5c49b9e0ab0c8caf4832": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Water Heater\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "30", + "info/name": "Water Heater", + "info/spaces": "31,33", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-4500.0", + "meter/current": "18.75", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "26", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "4d1deb6acb065746b13207b1358f8ca7": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Dishwasher\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Dishwasher", + "info/spaces": "16", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "0.0", + "meter/current": "0.0", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "15", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "516694a326a35cd88600b3520e8a981a": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Pool Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Pool Pump", + "info/spaces": "39", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-245.4939312250361", + "meter/current": "2.0457827602086343", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "20", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "6fcb352679ad5bfb8c8a8eab06829b9f": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "30", + "info/name": "Solar Inverter", + "info/spaces": "36,38", + "load-shed/priority": "NEVER", + "meter/active-power": "3586.7736084560265", + "meter/current": "14.944890035233444", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "false", + "pcs/priority": "29", + "switch/relay": "CLOSED", + "switch/relay-controllable": "false", + "switch/relay-requester": "UNKNOWN" + }, + "770e2de52c33508a8a9ee8878064b46f": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Master Bedroom Lights", + "info/spaces": "1", + "load-shed/priority": "NEVER", + "meter/active-power": "-17.09164649643716", + "meter/current": "0.14243038747030967", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "1", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "80a4fada833156ab8112f9d50e252b8f": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Counter)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Kitchen Outlets (Counter)", + "info/spaces": "9", + "load-shed/priority": "NEVER", + "meter/active-power": "-257.52029126837186", + "meter/current": "2.146002427236432", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "8", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "9429f828509e58d59cb5f0f9f5fee523": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Living Room Lights", + "info/spaces": "2", + "load-shed/priority": "NEVER", + "meter/active-power": "-19.63772187888649", + "meter/current": "0.1636476823240541", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "2", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "948dea7788aa5c959b99df0edfabead2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Heat Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "30", + "info/name": "Heat Pump", + "info/spaces": "27,29", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-2067.125798359877", + "meter/current": "8.613024159832822", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "24", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "af731c49a6785a4cb2ea5549fb8bce7e": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Main HVAC\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "30", + "info/name": "Main HVAC", + "info/spaces": "23,25", + "load-shed/priority": "NEVER", + "meter/active-power": "-1063.0560418593357", + "meter/current": "4.429400174413899", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "23", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "afe90839f2725e3e962fb05afa2b6d43": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Chest Freezer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Chest Freezer", + "info/spaces": "19", + "load-shed/priority": "NEVER", + "meter/active-power": "-73.65733206593038", + "meter/current": "0.6138111005494198", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "false", + "pcs/priority": "18", + "switch/relay": "CLOSED", + "switch/relay-controllable": "false", + "switch/relay-requester": "UNKNOWN" + }, + "b24483358d29589d8e91d3bf11113269": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Office Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Office Outlets", + "info/spaces": "11", + "load-shed/priority": "NEVER", + "meter/active-power": "-316.9986735456939", + "meter/current": "2.6416556128807827", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "10", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "b9fa08f1eaaf5d129bd5c78e1d5d937f": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.circuit\", \"name\": \"kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "kitchen Lights", + "info/spaces": "3", + "load-shed/priority": "NEVER", + "meter/active-power": "-135.18581560086707", + "meter/current": "1.126548463340559", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "30", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "be7742043a06554aab2a1e38cc776603": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Oven/Range\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "40", + "info/name": "Electric Oven/Range", + "info/spaces": "28,30", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-5000.0", + "meter/current": "20.833333333333332", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "25", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "bess": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/nameplate-capacity": "13.5", + "info/vendor-name": "Span", + "meter/active-power": "3500.0", + "soc/soc": "50.0", + "soc/soe": "6.75", + "status/communication-state": "OK" + }, + "c058aa11287f50f9b81e5160a0678869": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bathroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Bathroom Lights", + "info/spaces": "5", + "load-shed/priority": "NEVER", + "meter/active-power": "-13.23052838833021", + "meter/current": "0.11025440323608508", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "4", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "c339ec7ce7ff521ca7646f9606baff9f": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Guest Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Guest Room Outlets", + "info/spaces": "14", + "load-shed/priority": "NEVER", + "meter/active-power": "-128.97173258908555", + "meter/current": "1.0747644382423795", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "13", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "d1ff145887a05b839ede89409c27b398": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Garage Outlets", + "info/spaces": "12", + "load-shed/priority": "NEVER", + "meter/active-power": "-145.3516077123568", + "meter/current": "1.2112633976029734", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "11", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "e0ac90e169e6550ea83fe0b1942f1d0e": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Living Room Outlets", + "info/spaces": "8", + "load-shed/priority": "NEVER", + "meter/active-power": "-227.7013602466238", + "meter/current": "1.8975113353885318", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "7", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "e0bc156c85015a609d4132084dfcd6fe": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Microwave\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Microwave", + "info/spaces": "18", + "load-shed/priority": "NEVER", + "meter/active-power": "-1500.0", + "meter/current": "12.5", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "17", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "edee3425d50d51ffb022ee999053b2b4": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Laundry Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Laundry Room Outlets", + "info/spaces": "13", + "load-shed/priority": "NEVER", + "meter/active-power": "-145.1029999098799", + "meter/current": "1.2091916659156658", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "12", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "ef972f063451539e8b2ad88e831d87b6": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Dryer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "30", + "info/name": "Electric Dryer", + "info/spaces": "20,22", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-5000.0", + "meter/current": "20.833333333333332", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "22", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "evse": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32.0", + "config/user-max-charge-current": "32.0", + "info/firmware-version": "sim/v0.1.0", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-sim-40t-001", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "AVAILABLE", + "switch/lock-state": "UNLOCKED" + }, + "evse-2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32.0", + "config/user-max-charge-current": "32.0", + "info/firmware-version": "sim/v0.1.0", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-sim-40t-001-2", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "AVAILABLE", + "switch/lock-state": "UNLOCKED" + }, + "f515a0f43b6555b1a196fbb62728c24e": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Exterior Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Exterior Lights", + "info/spaces": "6", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "0.0", + "meter/current": "0.0", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "5", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "UNKNOWN" + }, + "lugs-downstream": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/direction": "DOWNSTREAM", + "meter/active-power": "18822.06352687105", + "meter/current-a": "108.41411763764835", + "meter/current-b": "108.21597189387751", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0" + }, + "lugs-upstream": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/direction": "UPSTREAM", + "meter/active-power": "18822.06352687105", + "meter/current-a": "108.41411763764835", + "meter/current-b": "108.21597189387751", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0" + }, + "pv": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/vendor-name": "Enphase" + }, + "sim-40t-001": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Span Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"770e2de52c33508a8a9ee8878064b46f\", \"9429f828509e58d59cb5f0f9f5fee523\", \"3d9d86f303cc50d1827be57d4c667e53\", \"c058aa11287f50f9b81e5160a0678869\", \"f515a0f43b6555b1a196fbb62728c24e\", \"3eeb0eb1605e5a7eadac41994b7a096c\", \"e0ac90e169e6550ea83fe0b1942f1d0e\", \"80a4fada833156ab8112f9d50e252b8f\", \"13044bfbcbe5554b8f3dba126bce828f\", \"b24483358d29589d8e91d3bf11113269\", \"d1ff145887a05b839ede89409c27b398\", \"edee3425d50d51ffb022ee999053b2b4\", \"c339ec7ce7ff521ca7646f9606baff9f\", \"2140a7e253ed54e3bc90a959081df615\", \"4d1deb6acb065746b13207b1358f8ca7\", \"43a0521737db516f99f14a9964ea4af0\", \"e0bc156c85015a609d4132084dfcd6fe\", \"afe90839f2725e3e962fb05afa2b6d43\", \"4aeb08c46c2c5905a944166413f2f1ef\", \"516694a326a35cd88600b3520e8a981a\", \"1eeeb748eeaa58edb7e9b7e9dbbdeca7\", \"ef972f063451539e8b2ad88e831d87b6\", \"af731c49a6785a4cb2ea5549fb8bce7e\", \"948dea7788aa5c959b99df0edfabead2\", \"be7742043a06554aab2a1e38cc776603\", \"4ce8b30e8d3f5c49b9e0ab0c8caf4832\", \"249a2f59782e5f1ab317c4632e79afad\", \"1bfdc7ecebb0547bbe87a3696cddb0c0\", \"6fcb352679ad5bfb8c8a8eab06829b9f\", \"b9fa08f1eaaf5d129bd5c78e1d5d937f\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", + "$state": "ready", + "breaker/rating": "200", + "door/state": "CLOSED", + "info/data-model-version": "1.0", + "info/firmware-version": "sim/v0.1.0", + "info/hardware-version": "rev2", + "info/model": "MAIN_40", + "info/serial-number": "sim-40t-001", + "info/vendor-name": "Span", + "meter/voltage-a": "120.0", + "meter/voltage-b": "120.0", + "pcs/active": "false", + "pcs/binding-constraint": "NONE", + "pcs/enabled": "false", + "pcs/feed-import-limit": "0.0", + "pcs/feed-import-limit-active": "false", + "pcs/feed-import-limit-enablement": "UNCONFIGURED", + "pcs/import-limit": "0.0", + "pcs/off-grid-import-limit": "0.0", + "pcs/off-grid-import-limit-active": "false", + "pcs/off-grid-import-limit-enablement": "UNCONFIGURED", + "pcs/operator-import-limit": "0.0", + "pcs/operator-import-limit-active": "false", + "pcs/operator-import-limit-enablement": "UNCONFIGURED", + "pcs/requested-import-limit": "0.0", + "pcs/requested-import-limit-active": "false", + "pcs/requested-import-limit-enablement": "UNCONFIGURED", + "power-flows/battery": "3500.0", + "power-flows/grid": "15322.06352687105", + "power-flows/pv": "3586.7736084560265", + "power-flows/site": "22408.837135327078", + "shed/asserted-islanding-state": "NONE", + "status/cloud-connection": "CONNECTED", + "status/ethernet": "true", + "status/postal-code": "94103", + "status/relay": "CLOSED", + "status/time-zone": "America/Los_Angeles", + "status/wifi": "true" + } +} diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json index f0e9c6b..0e6048a 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -12,12 +12,15 @@ "framework": "0.7", "peer": { "repo": "https://github.com/SpanPanel/panelbench", - "ref": "main", + "ref": "feat/wire-capture", "role": "publisher", - "commit": "b6f638850cf75acd14c181517b08ca7be7f866c1", + "commit": "7e170a3", "synced_commit": "4254526b0a8c11cab0d40fd700b1fc295c0479c6", "firmware_range": "r202633+", - "fixture": "tests/conformance/fixtures/golden_tree.json" + "fixtures": { + "tree": "tests/conformance/fixtures/golden_tree.json", + "wire": "tests/conformance/fixtures/golden_wire.json" + } }, "implements": { "capabilities": { diff --git a/tests/test_schema_one_against_simulator.py b/tests/test_schema_one_against_simulator.py new file mode 100644 index 0000000..625d645 --- /dev/null +++ b/tests/test_schema_one_against_simulator.py @@ -0,0 +1,161 @@ +"""Drive the parser end to end from what the simulator actually publishes. + +Every other schema_1 test runs on `fixtures/parent_child_tree.json`, which was +captured off the upstream *generic* eBus panel simulator. That fixture is fine +for exercising the mapper, but it is not SPAN: it has never carried the +extensions and divergences that are SPAN's own vocabulary, which is precisely +the part a generic panel cannot produce. + +This runs on a capture from SPAN's own publisher — the same panel the +conformance and coverage checks are written against — fed in exactly as the +transport feeds it: one retained message at a time, in whatever order the store +replays them. + +**Values are deliberately not asserted.** The simulator's config carries +`noise_factor` and its clock advances, so power and current differ every capture. +Pinning a wattage here would produce a test that fails whenever the fixture is +refreshed, for a reason nobody can act on. What is asserted is what must hold for +any capture of a 40-space panel: that the parser reaches ready, sizes the panel, +finds every circuit, and populates the fields the integration consumes. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from span_panel_api.models import V2HomieSchema +from span_panel_api_schema_1 import SchemaOneAdapter + +_WIRE = Path(__file__).parent.parent / "packages" / "schema-1" / "spec" / "fixtures" / "simulator_wire.json" +_PANEL = "sim-40t-001" +_TOPIC_PREFIX = "ebus/5" + + +def _schema() -> V2HomieSchema: + return V2HomieSchema( + firmware_version="spanos2/r202633/01", + types_schema_hash="sha256:simulator-capture", + types={}, + data_model_version="1.0", + ) + + +@pytest.fixture(name="adapter") +def _adapter() -> SchemaOneAdapter: + """Feed the capture the way the broker replays it. + + Sorted by topic rather than tree order, on purpose: the retained store has no + notion of parents before children, and the ordering bug fixed before 0.1.0b1 + was exactly a case of that assumption being made silently. + """ + with _WIRE.open() as handle: + capture: dict[str, dict[str, str]] = json.load(handle) + + adapter = SchemaOneAdapter(_PANEL, _schema()) + messages = [ + (f"{_TOPIC_PREFIX}/{device_id}/{key}", payload) + for device_id, body in capture.items() + for key, payload in body.items() + ] + for topic, payload in sorted(messages): + adapter.handle_message(topic, payload) + return adapter + + +def test_the_parser_reaches_ready_on_the_simulators_own_capture(adapter: SchemaOneAdapter) -> None: + """The claim that matters: this parser can complete a connection to SPAN's + publisher, not merely to a generic eBus panel.""" + assert adapter.is_ready(), "the parser never reached ready on a full capture of the simulator's tree" + + +def test_the_panel_is_sized_from_the_model_the_simulator_declares(adapter: SchemaOneAdapter) -> None: + """Panel size drives the unmapped-position entries the integration builds + from total-minus-occupied, so a wrong size is missing entities, not an error.""" + snapshot = adapter.build_snapshot() + + assert snapshot.panel_size == 40, "the simulator declares MAIN_40; PANEL_SIZE_BY_MODEL must know it" + + +def test_every_circuit_the_simulator_publishes_is_parsed(adapter: SchemaOneAdapter) -> None: + """30 circuits in the tracked config; the remainder of the 40 spaces are the + unmapped positions the integration expects to exist.""" + snapshot = adapter.build_snapshot() + real = [circuit_id for circuit_id in snapshot.circuits if not circuit_id.startswith("unmapped_tab_")] + + assert len(real) == 30, f"expected the config's 30 circuits, parsed {len(real)}" + assert all(snapshot.circuits[circuit_id].name for circuit_id in real), "a circuit arrived with no name" + + +def test_the_ders_declare_a_model_they_never_publish(adapter: SchemaOneAdapter) -> None: + """A producer-side gap, pinned so it cannot fade into the background. + + Every DER the simulator publishes declares `info/model` in its + `$description` and never sends a value for it. PV is the widest: it declares + firmware-version, model, nominal-power, serial-number and vendor-name, and + publishes vendor-name alone. + + That breaks the one standing obligation eBus places on a publisher — be + self-describing, declare accurately what you publish — and it is the failure + mode this parser's `circuit_nodes_missing_names()` exists to surface: a + consumer waits on a value that is promised and never arrives, so the entity + is created and never updates. + + Worth knowing that panelbench's own conformance checker **cannot** catch + this. It compares declarations against catalogs, so a property declared and + never published is conformant by construction. Only a capture that carries + values can see it, which is the argument for this fixture existing. + + Pinned rather than asserted away: when the simulator publishes these, this + test fails and the expectation gets deleted. + """ + assert adapter.circuit_nodes_missing_names() == ["bess", "pv", "evse", "evse-2"], ( + "the set of devices declaring a model they never publish has changed. If the simulator " + "now publishes them, delete this test and assert circuit_nodes_missing_names() is empty." + ) + + +def test_the_fields_the_integration_consumes_are_populated(adapter: SchemaOneAdapter) -> None: + """Presence, not values. A field left None reaches a user as an entity that + exists and never updates, which is the failure this whole exercise is about. + """ + snapshot = adapter.build_snapshot() + + assert snapshot.instant_grid_power_w is not None + assert snapshot.main_meter_energy_consumed_wh is not None + assert snapshot.main_meter_energy_produced_wh is not None + assert snapshot.battery.soe_percentage is not None + assert snapshot.l1_voltage is not None + assert snapshot.l2_voltage is not None + + +def test_field_metadata_covers_what_the_snapshot_carries(adapter: SchemaOneAdapter) -> None: + """Metadata is read from each device's `$description`, so a capture is the + only way to check it against a real publisher rather than against a schema + document that describes every panel ever built.""" + metadata = adapter.build_field_metadata() + + assert metadata, "no field metadata was built from a full capture" + assert all( + entry.unit != "energy" for entry in metadata.values() + ), "an abstract unit token reached field metadata; units must come from the device description" + + +def test_grid_state_is_absent_because_the_simulator_publishes_no_mid(adapter: SchemaOneAdapter) -> None: + """The one gap, asserted rather than left to be noticed. + + `grid_state` reads the MID's `grid/islanding-state`. The simulator supports a + MID fully — profile, resolvers, snapshot field — but nothing instantiates one, + so no config produces it and this capture cannot exercise the mapping. + + Pinned as an expectation so that the day the simulator does publish a MID, + this fails and says so, rather than the gap quietly persisting behind a + passing suite. Its counterpart is `_NOT_EXERCISED_BY_SIMULATOR` in + `test_schema_one_conformance.py`; both must be cleared together. + """ + assert adapter.build_snapshot().grid_state is None, ( + "the simulator now publishes a MID. Drop this test, and drop grid/islanding-state " + "from _NOT_EXERCISED_BY_SIMULATOR so the coverage check holds it instead." + ) diff --git a/tests/test_schema_one_conformance.py b/tests/test_schema_one_conformance.py index 70b50db..f4a28ed 100644 --- a/tests/test_schema_one_conformance.py +++ b/tests/test_schema_one_conformance.py @@ -52,6 +52,7 @@ _CATALOGS = _SPEC / "catalogs" _DEVICE_TYPES = _SPEC / "registries" / "device-types.md" _SIMULATOR_TREE = _SPEC / "fixtures" / "simulator_tree.json" +_SIMULATOR_WIRE = _SPEC / "fixtures" / "simulator_wire.json" _SOURCE = Path(const.__file__).parent _LOCK = _SOURCE / "spec_lock.json" @@ -62,10 +63,31 @@ def _lock() -> dict[str, object]: return loaded -def _peer() -> dict[str, str]: +def _peer() -> dict[str, object]: peer = _lock()["peer"] assert isinstance(peer, dict) - return {str(key): str(value) for key, value in peer.items()} + return peer + + +def _peer_str(key: str) -> str: + value = _peer()[key] + assert isinstance(value, str), f"peer.{key} should be a string" + return value + + +def _peer_fixtures() -> dict[str, str]: + """The captures vendored from the peer, by kind. + + Two of them, answering different questions: `tree` is `$description` + documents and is what the conformance profile is computed from; `wire` adds + `$state` and every property value, and is the only one that can drive this + parser end to end. A consumer checked against declarations alone has been + checked for understanding the shape of a panel, not for building the right + snapshot from one. + """ + fixtures = _peer()["fixtures"] + assert isinstance(fixtures, dict) + return {str(kind): str(path) for kind, path in fixtures.items()} def _catalog(node: str) -> dict[str, object]: @@ -322,7 +344,7 @@ def test_the_peer_is_pinned_to_the_same_specification_commit() -> None: everywhere. Its real job is to make bumping our own pin without looking at the other side impossible to do quietly. """ - assert _peer()["synced_commit"] == _lock()["synced_commit"], ( + assert _peer_str("synced_commit") == _lock()["synced_commit"], ( "this adapter and the simulator it is developed against are pinned to different " "specification commits; re-vendor both, or record why they may differ." ) @@ -334,7 +356,7 @@ def test_the_peer_targets_the_same_firmware() -> None: firmware = _lock()["firmware"] assert isinstance(firmware, dict) - assert _peer()["firmware_range"] == firmware["range"] + assert _peer_str("firmware_range") == firmware["range"] def test_every_property_read_is_exercised_by_the_simulator() -> None: @@ -402,18 +424,39 @@ def test_vendored_catalogs_are_byte_identical_to_the_specification() -> None: ) -def test_the_vendored_simulator_tree_matches_the_simulator() -> None: - """The captured tree against the simulator that produced it.""" +def test_the_vendored_captures_match_the_simulator() -> None: + """Both captures against the simulator that produced them. + + Byte comparison for the tree, whose content is deterministic. The wire + capture carries values perturbed by `noise_factor` and an advancing clock, so + it is compared on shape: same devices, same topics. Holding it to bytes would + fail on every recapture for a reason nobody can act on. + """ sim_dir = os.environ.get("SPAN_SIMULATOR_DIR") if not sim_dir: - pytest.skip("set SPAN_SIMULATOR_DIR to a simulator checkout to verify the captured tree") + pytest.skip("set SPAN_SIMULATOR_DIR to a simulator checkout to verify the captured fixtures") - peer = _peer() - source = Path(sim_dir) / peer["fixture"] - assert source.exists(), f"{source} is missing; is {sim_dir} on {peer['ref']}?" + fixtures = _peer_fixtures() + ref, commit = _peer_str("ref"), _peer_str("commit") - assert source.read_bytes() == _SIMULATOR_TREE.read_bytes(), ( - f"the captured tree differs from {source}. Re-capture it and update peer.commit " f"(recorded: {peer['commit']})." + tree_source = Path(sim_dir) / fixtures["tree"] + assert tree_source.exists(), f"{tree_source} is missing; is {sim_dir} on {ref}?" + assert tree_source.read_bytes() == _SIMULATOR_TREE.read_bytes(), ( + f"the captured tree differs from {tree_source}. Re-capture it and update peer.commit " f"(recorded: {commit})." + ) + + wire_source = Path(sim_dir) / fixtures["wire"] + assert wire_source.exists(), f"{wire_source} is missing; is {sim_dir} on {ref}?" + with wire_source.open() as handle: + theirs = json.load(handle) + with _SIMULATOR_WIRE.open() as handle: + ours = json.load(handle) + + assert set(theirs) == set(ours), "the simulator now publishes a different device set than the vendored capture" + differing = sorted(device for device in ours if set(ours[device]) != set(theirs[device])) + assert not differing, ( + f"these devices publish different topics than the vendored capture: {differing}. " + f"Re-vendor from {wire_source} and update peer.commit (recorded: {commit})." ) @@ -425,10 +468,8 @@ def test_the_peer_record_matches_the_simulator_lockfile() -> None: with (Path(sim_dir) / ".ebus-spec.json").open() as handle: theirs = json.load(handle) - peer = _peer() - - assert theirs["role"] == peer["role"], "the peer is not publishing; this pairing is not what it claims" - assert theirs["synced_commit"] == peer["synced_commit"], ( - f"the simulator now pins {theirs['synced_commit']}, we recorded {peer['synced_commit']}. " + assert theirs["role"] == _peer_str("role"), "the peer is not publishing; this pairing is not what it claims" + assert theirs["synced_commit"] == _peer_str("synced_commit"), ( + f"the simulator now pins {theirs['synced_commit']}, we recorded {_peer_str('synced_commit')}. " "Re-vendor and update both, or the two sides are reading different vocabularies." ) From b381d86d639b67c8265d1683be0be1b1fe053160 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:11:40 -0700 Subject: [PATCH 041/115] chore(schema_1): take the sibling checkout paths from .env SPAN_SIMULATOR_DIR becomes PANELBENCH_DIR. The producer is no longer called the simulator, and a variable naming the old repository would be the last place anyone thought to look when it stopped resolving. Adds .env.example and a loader in tests/conftest.py, because this repository had no mechanism at all -- the two provenance checks could only run for someone who remembered to export the paths inline, which in practice meant they ran when I happened to type them and never otherwise. The loader reads the file directly rather than taking python-dotenv: it parses two lines, and a package in the test path to do that buys nothing. It uses setdefault, never assignment, so an exported value still wins -- pointing at a different checkout to reproduce something is a deliberate choice for that run, and a file quietly overriding it is a bad afternoon. Both checks now resolve their checkout through one helper that skips when the variable is unset *or* names a directory that is gone. Those are the same situation -- no checkout available -- and the previous code only handled the first, so a stale path produced a FileNotFoundError from inside a byte comparison. That reads as a broken test rather than an unconfigured one. CI is unaffected: with no .env and nothing exported, all three provenance checks skip and the conformance and coverage checks -- the ones that catch real defects -- still run. --- .env.example | 34 +++++++++++++++++++++++ packages/schema-1/CHANGELOG.md | 4 +-- tests/conftest.py | 30 ++++++++++++++++++++ tests/test_schema_one_conformance.py | 41 +++++++++++++++++----------- 4 files changed, 91 insertions(+), 18 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f9ee308 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# Local-developer environment variables for span-panel-api. +# +# Copy to `.env` and fill in. `.env` is gitignored and must stay that way. +# +# `tests/conftest.py` reads this file directly, so no direnv or dotenv package is +# needed. A value already exported in your shell wins over anything here — the +# file supplies defaults, it does not override an intentional choice. +# +# Everything below is optional. Without it the suite runs in full and the checks +# that need a sibling checkout skip themselves rather than fail, which is what CI +# does. They are the *provenance* half of the schema_1 conformance suite: they +# verify that the vendored copies still match their sources. The conformance and +# coverage checks, which are the ones that catch real defects, run regardless. + +# A checkout of the eBus specification. +# +# git clone https://github.com/electrification-bus/specification +# +# Enables the byte comparison of `packages/schema-1/spec/catalogs/*.json` against +# the specification's `capabilities/`. Position the checkout at the commit +# `spec_lock.json` pins (`synced_commit`) before believing a failure — a checkout +# on a newer HEAD reports differences that are drift, not corruption. +#EBUS_SPEC_DIR=/path/to/specification + +# A checkout of SpanPanel/panelbench, the publisher this parser is developed +# against. +# +# git clone git@github.com:SpanPanel/panelbench.git +# +# Enables verifying the two vendored captures and the recorded peer pins against +# the producer itself. The tree capture is compared byte for byte; the wire +# capture is compared on shape, because its values are perturbed by the +# simulator's `noise_factor` and an advancing clock. +#PANELBENCH_DIR=/path/to/panelbench diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index 70278a5..ff5848d 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -37,8 +37,8 @@ number. A release here means this parser changed, never that the panel did. declares five `info` properties and publishes one). The second breaks the single standing obligation eBus places on a publisher, to declare accurately what it publishes, and is invisible to a conformance checker: comparing declarations against catalogs cannot see a declaration nothing fulfils. Only a capture carrying values can, which is the argument for this fixture existing. Both are asserted as current expectations, so closing either fails the test that describes it. -Provenance (byte comparison against a specification or simulator checkout) is skipped unless `EBUS_SPEC_DIR` / `SPAN_SIMULATOR_DIR` are set, so conformance and coverage run everywhere while the byte checks stay opportunistic. The wire capture is compared -on shape rather than bytes for the same reason its values are not asserted. Provenance proves the right bytes were copied; it cannot prove they were understood, which is what the other two are for. +Provenance (byte comparison against a specification or simulator checkout) is skipped unless `EBUS_SPEC_DIR` / `PANELBENCH_DIR` are set, so conformance and coverage run everywhere while the byte checks stay opportunistic. The wire capture is compared on +shape rather than bytes for the same reason its values are not asserted. Provenance proves the right bytes were copied; it cannot prove they were understood, which is what the other two are for. ### Fixed diff --git a/tests/conftest.py b/tests/conftest.py index aab235d..8d9d026 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,8 @@ import asyncio import json +import os +from pathlib import Path from collections.abc import AsyncGenerator from unittest.mock import MagicMock, patch @@ -16,6 +18,34 @@ from span_panel_api.models import V2HomieSchema from span_panel_api_schema_0.const import TOPIC_PREFIX, TYPE_CORE +_DOTENV = Path(__file__).parent.parent / ".env" + + +def _load_dotenv() -> None: + """Populate the environment from `.env`, without overriding what is set. + + Read directly rather than through python-dotenv: this supplies developer + defaults for the optional provenance checks (`EBUS_SPEC_DIR`, + `PANELBENCH_DIR`), and taking a dependency to parse two lines would put a + package in the test path to save nothing. + + `setdefault`, never assignment. An exported value is a deliberate choice for + this run — pointing at a different checkout to reproduce something — and a + file silently winning over it is the kind of surprise that costs an + afternoon. See `.env.example`; absence is fine, the checks skip. + """ + if not _DOTENV.exists(): + return + for raw in _DOTENV.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +_load_dotenv() + @pytest.fixture(autouse=True) def _reset_ssl_cache() -> None: diff --git a/tests/test_schema_one_conformance.py b/tests/test_schema_one_conformance.py index f4a28ed..448cfac 100644 --- a/tests/test_schema_one_conformance.py +++ b/tests/test_schema_one_conformance.py @@ -22,7 +22,7 @@ - **Coverage** — this adapter against a captured tree from the SPAN simulator, the producer our development is done against. Always runs, from a vendored copy. - **Provenance** — the vendored copies against their sources. Skipped unless - `EBUS_SPEC_DIR` / `SPAN_SIMULATOR_DIR` point at checkouts. + `EBUS_SPEC_DIR` / `PANELBENCH_DIR` point at checkouts. Provenance proves we copied the right bytes; it cannot prove we understood them. The first two are where the understanding gets checked, which is why they are the @@ -90,6 +90,24 @@ def _peer_fixtures() -> dict[str, str]: return {str(kind): str(path) for kind, path in fixtures.items()} +def _checkout(variable: str, what: str) -> Path: + """A sibling checkout named by an environment variable, or skip. + + A variable that is unset and one pointing at a directory that is gone are the + same situation — the checkout is not available — and both should skip. Letting + a stale path through instead produces a FileNotFoundError from somewhere deep + in a comparison, which reads as a broken test rather than an unconfigured one. + Set them in `.env`; see `.env.example`. + """ + configured = os.environ.get(variable) + if not configured: + pytest.skip(f"set {variable} to {what}") + path = Path(configured) + if not path.is_dir(): + pytest.skip(f"{variable}={configured} does not exist; point it at {what}") + return path + + def _catalog(node: str) -> dict[str, object]: with (_CATALOGS / f"{node}.json").open() as handle: loaded: dict[str, object] = json.load(handle) @@ -407,11 +425,7 @@ def test_vendored_catalogs_are_byte_identical_to_the_specification() -> None: that must run everywhere, and making them depend on a second repository would mean they stop running. """ - spec_dir = os.environ.get("EBUS_SPEC_DIR") - if not spec_dir: - pytest.skip("set EBUS_SPEC_DIR to a specification checkout to verify vendored bytes") - - spec = Path(spec_dir) + spec = _checkout("EBUS_SPEC_DIR", "a specification checkout to verify vendored bytes") differing = [ path.name for path in sorted(_CATALOGS.glob("*.json")) @@ -432,20 +446,17 @@ def test_the_vendored_captures_match_the_simulator() -> None: it is compared on shape: same devices, same topics. Holding it to bytes would fail on every recapture for a reason nobody can act on. """ - sim_dir = os.environ.get("SPAN_SIMULATOR_DIR") - if not sim_dir: - pytest.skip("set SPAN_SIMULATOR_DIR to a simulator checkout to verify the captured fixtures") - + sim_dir = _checkout("PANELBENCH_DIR", "a panelbench checkout to verify the captured fixtures") fixtures = _peer_fixtures() ref, commit = _peer_str("ref"), _peer_str("commit") - tree_source = Path(sim_dir) / fixtures["tree"] + tree_source = sim_dir / fixtures["tree"] assert tree_source.exists(), f"{tree_source} is missing; is {sim_dir} on {ref}?" assert tree_source.read_bytes() == _SIMULATOR_TREE.read_bytes(), ( f"the captured tree differs from {tree_source}. Re-capture it and update peer.commit " f"(recorded: {commit})." ) - wire_source = Path(sim_dir) / fixtures["wire"] + wire_source = sim_dir / fixtures["wire"] assert wire_source.exists(), f"{wire_source} is missing; is {sim_dir} on {ref}?" with wire_source.open() as handle: theirs = json.load(handle) @@ -462,11 +473,9 @@ def test_the_vendored_captures_match_the_simulator() -> None: def test_the_peer_record_matches_the_simulator_lockfile() -> None: """What we believe the producer pins, against what it actually pins.""" - sim_dir = os.environ.get("SPAN_SIMULATOR_DIR") - if not sim_dir: - pytest.skip("set SPAN_SIMULATOR_DIR to a simulator checkout to verify the peer record") + sim_dir = _checkout("PANELBENCH_DIR", "a panelbench checkout to verify the peer record") - with (Path(sim_dir) / ".ebus-spec.json").open() as handle: + with (sim_dir / ".ebus-spec.json").open() as handle: theirs = json.load(handle) assert theirs["role"] == _peer_str("role"), "the peer is not publishing; this pairing is not what it claims" assert theirs["synced_commit"] == _peer_str("synced_commit"), ( From 3abb063aac550a59719a28af6ae27d9f8e3f3ee1 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:03:56 -0700 Subject: [PATCH 042/115] chore(schema_1): peer now points at panelbench main The wire-capture PR merged, so the branch coordinates recorded while it was in flight are no longer the truth. ref returns to main and commit names the merged head, which also carries a dependabot bump of actions/cache. Recorded as a full SHA rather than the abbreviation used while the branch was open. The other commit fields here are full, and a lockfile is the wrong place for a value that has to be expanded before it can be compared. Verified against the repository rather than assumed: panelbench main resolves to this SHA, both recorded fixture paths exist there, and the provenance checks pass against that checkout -- the tree byte for byte, the wire on shape. --- packages/schema-1/src/span_panel_api_schema_1/spec_lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json index 0e6048a..ecdd663 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -12,9 +12,9 @@ "framework": "0.7", "peer": { "repo": "https://github.com/SpanPanel/panelbench", - "ref": "feat/wire-capture", + "ref": "main", "role": "publisher", - "commit": "7e170a3", + "commit": "266ff31b0fe4a7e515813201f6f0ac38c3cd1feb", "synced_commit": "4254526b0a8c11cab0d40fd700b1fc295c0479c6", "firmware_range": "r202633+", "fixtures": { From 0d0d2e003c12820fdb2fdcbe30af64af1c6ef809 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:48:49 -0700 Subject: [PATCH 043/115] feat(schema_1): take tree completeness from the SDK, not a walk of our own ebus-sdk 0.19.0 adds Controller.is_tree_complete(), a first-class answer to "has the declared tree fully described itself?". schema_1 had hand-rolled that question, and the SDK's own consumer guide (doc/consuming-a-homie-tree.md, new in 0.18.1) names hand-rolling it as the road to a one-shot barrier that stops reconciling and silently misses a device commissioned later. Ours was not that barrier -- the transport consults is_ready() on every snapshot, so it already re-walked and flipped back -- but there is no reason to keep a second implementation of a question the controller can answer about the tree it actually holds. The SDK's also terminates on a declared cycle by construction rather than by our walk happening to be a single pass. _awaiting_descriptions survives as the diagnostic the predicate does not provide: which devices are outstanding, not merely whether any are. It runs unconditionally so the pending set stays accurate, because the alternative when a tree never completes is a bare 30-second connect timeout naming nothing. It is diagnostic only -- nothing reads it back, and no production path here inspects logs for state. The floor moves to ebus-sdk>=0.19.0, which is also where refresh_tree() became best-effort per child, so one raising device can no longer abort a whole tree's reconnect. The mypy and pylint hook environments move with it. The new test pins the reconciling behaviour directly: declaring a child nobody has heard from returns readiness to False, and it recovers when that child describes itself. --- .pre-commit-config.yaml | 4 +-- packages/schema-1/pyproject.toml | 2 +- .../src/span_panel_api_schema_1/adapter.py | 19 +++++++++++- tests/test_schema_one_adapter.py | 31 +++++++++++++++++++ uv.lock | 8 ++--- 5 files changed, 56 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4e8c6a0..cc6c95c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -85,7 +85,7 @@ repos: # schema-1 parses the parent/child tree with the eBus SDK, which # ships py.typed — so the hook needs it installed to resolve those # types rather than silently reporting import-not-found. - - ebus-sdk>=0.18.0 + - ebus-sdk>=0.19.0 args: ['--config-file=pyproject.toml'] exclude: '^src/span_panel_api/generated_client/.*|scripts/.*|tests/.*|docs/.*|examples/.*|\..*_cache/.*|dist/.*|venv/.*' @@ -103,7 +103,7 @@ repos: - paho-mqtt # schema-1 imports the eBus SDK; without it here the hook reports # import-error for a dependency that is correctly declared. - - ebus-sdk>=0.18.0 + - ebus-sdk>=0.19.0 exclude: '^src/span_panel_api/generated_client/.*|tests/.*|generate_client\.py|scripts/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|^examples/.*' # Check for common security issues diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index 239de4f..53d48d3 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ # schema-0 stay clean, so a flat-panel install never pulls it in — which is # what bounds the release coupling this dependency introduces to panels on # r202633+. - "ebus-sdk>=0.18.0,<1.0", + "ebus-sdk>=0.19.0,<1.0", ] [project.urls] diff --git a/packages/schema-1/src/span_panel_api_schema_1/adapter.py b/packages/schema-1/src/span_panel_api_schema_1/adapter.py index 9232df4..081c455 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/adapter.py +++ b/packages/schema-1/src/span_panel_api_schema_1/adapter.py @@ -99,6 +99,18 @@ def is_ready(self) -> bool: handful of circuits and no model, which it reports as a healthy connection. So readiness waits for every device the tree declares. + Completeness comes from `Controller.is_tree_complete()` rather than a + walk of our own. It is the SDK's reconciling predicate for exactly this + question, it terminates on a declared cycle, and having one + implementation means our answer cannot drift from the tree the + controller actually holds. `_awaiting_descriptions` survives as the + diagnostic the predicate does not provide — which devices, not merely + whether. + + This is a predicate, never a barrier: the transport consults it on every + snapshot, so a device commissioned later correctly makes it False again + until that device describes itself. + Child *state* is deliberately not required. A commissioned DER that is currently offline publishes `lost` but keeps its retained description, and a panel should not fail to connect because a battery is unplugged. @@ -113,7 +125,12 @@ def is_ready(self) -> bool: root = self._controller.get_root(self._serial_number) if root is None or root.state != STATE_READY or not root.description: return False - if self._awaiting_descriptions(root): + # Diagnostic first, and unconditionally, so the pending set stays + # accurate: a tree that never completes then names the devices it is + # waiting on instead of expiring as a bare 30-second connect timeout, + # which `is_tree_complete()` alone cannot tell anyone. + self._awaiting_descriptions(root) + if not self._controller.is_tree_complete(self._serial_number): return False return self._model_arrived(root) diff --git a/tests/test_schema_one_adapter.py b/tests/test_schema_one_adapter.py index 4beeaed..141f264 100644 --- a/tests/test_schema_one_adapter.py +++ b/tests/test_schema_one_adapter.py @@ -133,6 +133,37 @@ def test_a_root_whose_children_are_still_arriving_is_not_ready() -> None: assert adapter.is_ready() is False +def test_readiness_goes_back_to_false_when_the_panel_declares_a_new_child(adapter: SchemaOneAdapter) -> None: + """Readiness is a reconciling predicate, not a barrier that latches. + + A Homie tree grows out of band: commission a circuit and the panel + republishes a `$description` naming a child nobody has heard from. The + common consumer defect is to treat the first ready as settled and stop + reconciling, so the new device is never seen — a failure that moves from + startup to steady state, which makes it harder to find rather than less + real. `ebus-sdk`'s `doc/consuming-a-homie-tree.md` names it the one-shot + barrier. + + The transport consults `is_ready()` on every snapshot, so this must fall + back to False and recover once the newcomer describes itself. + """ + assert adapter.is_ready() is True + + description = json.loads(_TREE[PANEL]["$description"]) + description["children"] = [*description.get("children", []), "circuit-38"] + adapter.handle_message(f"ebus/5/{PANEL}/$description", json.dumps(description)) + + assert adapter.is_ready() is False, "a declared but unheard-of child left readiness latched True" + + adapter.handle_message( + "ebus/5/circuit-38/$description", + json.dumps({"homie": "5.0", "name": "New circuit", "type": "energy.ebus.device.circuit", "nodes": {}}), + ) + adapter.handle_message("ebus/5/circuit-38/$state", "ready") + + assert adapter.is_ready() is True, "readiness did not recover once the new child described itself" + + def test_an_offline_child_does_not_block_readiness(adapter: SchemaOneAdapter) -> None: """A commissioned DER that is unplugged publishes `lost` but keeps its retained description. A panel must not fail to connect over it.""" diff --git a/uv.lock b/uv.lock index 3c6bd99..e177fa2 100644 --- a/uv.lock +++ b/uv.lock @@ -504,14 +504,14 @@ wheels = [ [[package]] name = "ebus-sdk" -version = "0.18.0" +version = "0.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ebus-mqtt-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/ca/7709e30078ffa1efb9cb6c6fe30ac11fe36c58a4f5b1f4c8b10dee6386a1/ebus_sdk-0.18.0.tar.gz", hash = "sha256:14c08a5fe3d9338045aeb89eb889364760f215db7ddad94140cecd569b51dc44", size = 145076, upload-time = "2026-08-05T22:41:34.305Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/45/044c4cd557850d7dc76e7ce664fedb5ce3ee4dbc2a58e97fb17e24991993/ebus_sdk-0.19.0.tar.gz", hash = "sha256:7987d3cae7c86e31656df9cd6e31e5a2ef950c757ba24f433adf019ca9aaa51c", size = 151155, upload-time = "2026-08-07T14:42:08.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/a9/4c01ee7efadbac8bdbfe8b2db553ef12fc4264a360092fe58b5777f1260b/ebus_sdk-0.18.0-py3-none-any.whl", hash = "sha256:7aec63a2023d295ba6a256e6dba6da915a03c02facdd1d67c597bacaa15e6673", size = 92178, upload-time = "2026-08-05T22:41:32.957Z" }, + { url = "https://files.pythonhosted.org/packages/1d/86/aad23b5bd10abb72c3d6bc659ffd67f6b19384aa9b5507050df707ee6cbb/ebus_sdk-0.19.0-py3-none-any.whl", hash = "sha256:33aeec8d61b88373b8d1902bb8338449644d0d5aa4d75835567ad76ffebefd10", size = 95231, upload-time = "2026-08-07T14:42:07.211Z" }, ] [[package]] @@ -1400,7 +1400,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "ebus-sdk", specifier = ">=0.18.0,<1.0" }, + { name = "ebus-sdk", specifier = ">=0.19.0,<1.0" }, { name = "span-panel-api", editable = "." }, ] From 5b94fd9dea45ffce9e791d43fd3a09bfb751d2ef Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:02:52 -0700 Subject: [PATCH 044/115] feat(schema_1): re-derive against the faithful producer, closing both tripwires Phase 2 step 1. The vendored captures came from panelbench main, which predates the producer fidelity work: a 37-device tree with no MID and DERs that declared identity they never sent. Re-vendored from the producer that reaches full structural parity with the reference eBus emitter. tree/wire devices 37 -> 38 (a MID, for the first time) wire topics 568 -> 600 Three tests failed on the recapture and all three were built to. That is the mechanism working, so each is resolved rather than relaxed. grid_state stops being None. It reads the MID's grid/islanding-state, and no tracked config had ever instantiated a MID, so the mapping had never once been exercised. The test inverts rather than disappears: it now asserts 'ON_GRID', not merely non-None, because the MID publishes islanding-state=ON_GRID alongside grid-state=UP and reading the wrong one is exactly the defect corrected on 2026-08-06. Asserting the value proves which property the reader reached. circuit_nodes_missing_names() reaches empty. bess, pv, evse and evse-2 each declared info/model and never published it -- an entity created from the declaration, waiting on a value that never arrives. Adopting the upstream emitter's DER metadata keys closed all four. Asserted empty rather than deleted, since zero is the state worth defending. _NOT_EXERCISED_BY_SIMULATOR is now empty, and that is a measurement: property reads the parser makes 42 of those, declared by the producer 42 excused 0 Full coverage of this parser's read surface against the capture it is developed against. The mechanism stays for the next gap; an empty dict is load-bearing, because the coverage check now holds every mapping with nothing excused. Falsified rather than asserted, each restored and cmp-verified: reader drifted onto grid/grid-state 1 failed (- ON_GRID / + UP), nothing else caught it a DER's info/model value dropped 1 failed, naming 'bess' peer.ref moves from main to feat/adopt-upstream-emitter, deliberately and temporarily. The faithful producer is not on panelbench main yet -- its branch is stacked on panelbench #5 -- so the record names a branch head to stay honest about what these fixtures actually came from. It goes back to main when both land. peer.synced_commit is unchanged and still agrees with the producer's own lockfile, so the two sides read the same vocabulary. Full suite 570 passed. ruff check clean; mypy clean. The two test files fail ruff format at HEAD as well, so that is left alone rather than mixed in here. --- .../spec/fixtures/simulator_tree.json | 136 +++++--- .../spec/fixtures/simulator_wire.json | 290 ++++++++++-------- .../span_panel_api_schema_1/spec_lock.json | 4 +- tests/test_schema_one_against_simulator.py | 79 ++--- tests/test_schema_one_conformance.py | 18 +- 5 files changed, 316 insertions(+), 211 deletions(-) diff --git a/packages/schema-1/spec/fixtures/simulator_tree.json b/packages/schema-1/spec/fixtures/simulator_tree.json index 1c1ddeb..cfccd8b 100644 --- a/packages/schema-1/spec/fixtures/simulator_tree.json +++ b/packages/schema-1/spec/fixtures/simulator_tree.json @@ -135,7 +135,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464082 + "version": 1786157193480 }, "1bfdc7ecebb0547bbe87a3696cddb0c0": { "children": [], @@ -273,7 +273,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464091 + "version": 1786157193483 }, "1eeeb748eeaa58edb7e9b7e9dbbdeca7": { "children": [], @@ -411,7 +411,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464088 + "version": 1786157193482 }, "2140a7e253ed54e3bc90a959081df615": { "children": [], @@ -549,7 +549,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464085 + "version": 1786157193481 }, "249a2f59782e5f1ab317c4632e79afad": { "children": [], @@ -687,7 +687,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464091 + "version": 1786157193483 }, "3d9d86f303cc50d1827be57d4c667e53": { "children": [], @@ -825,7 +825,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464080 + "version": 1786157193479 }, "3eeb0eb1605e5a7eadac41994b7a096c": { "children": [], @@ -963,7 +963,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464081 + "version": 1786157193479 }, "43a0521737db516f99f14a9964ea4af0": { "children": [], @@ -1101,7 +1101,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464086 + "version": 1786157193481 }, "4aeb08c46c2c5905a944166413f2f1ef": { "children": [], @@ -1239,7 +1239,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464087 + "version": 1786157193482 }, "4ce8b30e8d3f5c49b9e0ab0c8caf4832": { "children": [], @@ -1377,7 +1377,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464090 + "version": 1786157193483 }, "4d1deb6acb065746b13207b1358f8ca7": { "children": [], @@ -1515,7 +1515,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464085 + "version": 1786157193481 }, "516694a326a35cd88600b3520e8a981a": { "children": [], @@ -1653,7 +1653,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464088 + "version": 1786157193482 }, "6fcb352679ad5bfb8c8a8eab06829b9f": { "children": [], @@ -1791,7 +1791,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464091 + "version": 1786157193483 }, "770e2de52c33508a8a9ee8878064b46f": { "children": [], @@ -1929,7 +1929,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464079 + "version": 1786157193479 }, "80a4fada833156ab8112f9d50e252b8f": { "children": [], @@ -2067,7 +2067,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464082 + "version": 1786157193480 }, "9429f828509e58d59cb5f0f9f5fee523": { "children": [], @@ -2205,7 +2205,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464079 + "version": 1786157193479 }, "948dea7788aa5c959b99df0edfabead2": { "children": [], @@ -2343,7 +2343,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464089 + "version": 1786157193482 }, "af731c49a6785a4cb2ea5549fb8bce7e": { "children": [], @@ -2481,7 +2481,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464089 + "version": 1786157193482 }, "afe90839f2725e3e962fb05afa2b6d43": { "children": [], @@ -2619,7 +2619,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464086 + "version": 1786157193481 }, "b24483358d29589d8e91d3bf11113269": { "children": [], @@ -2757,7 +2757,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464083 + "version": 1786157193480 }, "b9fa08f1eaaf5d129bd5c78e1d5d937f": { "children": [], @@ -2895,7 +2895,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464092 + "version": 1786157193484 }, "be7742043a06554aab2a1e38cc776603": { "children": [], @@ -3033,10 +3033,12 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464090 + "version": 1786157193483 }, "bess": { - "children": [], + "children": [ + "bess-mid" + ], "extensions": [], "homie": "5.0", "name": "Battery", @@ -3114,7 +3116,65 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.bess", - "version": 1786054464078 + "version": 1786157193484 + }, + "bess-mid": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Microgrid Interconnect Device", + "nodes": { + "grid": { + "name": "grid", + "properties": { + "grid-forming-entity": { + "datatype": "string", + "name": "Identity of the currently grid-forming entity" + }, + "grid-state": { + "datatype": "enum", + "format": "UP,DOWN,DEGRADED,UNKNOWN", + "name": "Sensed grid condition" + }, + "islanding-state": { + "datatype": "enum", + "format": "ON_GRID,OFF_GRID,UNKNOWN", + "name": "Islanding state of the BESS-integrated grid-forming device" + } + }, + "type": "energy.ebus.capability.grid" + }, + "info": { + "name": "info", + "properties": { + "firmware-version": { + "datatype": "string", + "name": "Firmware version" + }, + "hardware-version": { + "datatype": "string", + "name": "Hardware version" + }, + "model": { + "datatype": "string", + "name": "Model" + }, + "serial-number": { + "datatype": "string", + "name": "Serial number" + }, + "vendor-name": { + "datatype": "string", + "name": "Vendor name" + } + }, + "type": "energy.ebus.capability.info" + } + }, + "parent": "bess", + "root": "sim-40t-001", + "type": "energy.ebus.device.mid", + "version": 1786157193484 }, "c058aa11287f50f9b81e5160a0678869": { "children": [], @@ -3252,7 +3312,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464080 + "version": 1786157193479 }, "c339ec7ce7ff521ca7646f9606baff9f": { "children": [], @@ -3390,7 +3450,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464084 + "version": 1786157193481 }, "d1ff145887a05b839ede89409c27b398": { "children": [], @@ -3528,7 +3588,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464083 + "version": 1786157193480 }, "e0ac90e169e6550ea83fe0b1942f1d0e": { "children": [], @@ -3666,7 +3726,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464081 + "version": 1786157193480 }, "e0bc156c85015a609d4132084dfcd6fe": { "children": [], @@ -3804,7 +3864,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464086 + "version": 1786157193481 }, "edee3425d50d51ffb022ee999053b2b4": { "children": [], @@ -3942,7 +4002,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464084 + "version": 1786157193480 }, "ef972f063451539e8b2ad88e831d87b6": { "children": [], @@ -4080,7 +4140,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464089 + "version": 1786157193482 }, "evse": { "children": [], @@ -4168,7 +4228,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.evse", - "version": 1786054464092 + "version": 1786157193484 }, "evse-2": { "children": [], @@ -4256,7 +4316,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.evse", - "version": 1786054464093 + "version": 1786157193484 }, "f515a0f43b6555b1a196fbb62728c24e": { "children": [], @@ -4394,7 +4454,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786054464080 + "version": 1786157193479 }, "lugs-downstream": { "children": [], @@ -4484,7 +4544,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.lugs", - "version": 1786054464093 + "version": 1786157193484 }, "lugs-upstream": { "children": [], @@ -4574,7 +4634,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.lugs", - "version": 1786054464093 + "version": 1786157193484 }, "pv": { "children": [], @@ -4613,7 +4673,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.pv", - "version": 1786054464094 + "version": 1786157193484 }, "sim-40t-001": { "children": [ @@ -4919,6 +4979,6 @@ } }, "type": "energy.ebus.device.distribution-enclosure", - "version": 1786054464078 + "version": 1786157193484 } } diff --git a/packages/schema-1/spec/fixtures/simulator_wire.json b/packages/schema-1/spec/fixtures/simulator_wire.json index 3d70790..04780fc 100644 --- a/packages/schema-1/spec/fixtures/simulator_wire.json +++ b/packages/schema-1/spec/fixtures/simulator_wire.json @@ -1,27 +1,30 @@ { "13044bfbcbe5554b8f3dba126bce828f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Island)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193480, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Island)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Kitchen Outlets (Island)", "info/spaces": "10", "load-shed/priority": "NEVER", - "meter/active-power": "-320.76944740178845", - "meter/current": "2.673078728348237", + "meter/active-power": "-266.9639394044684", + "meter/current": "2.2246994950372367", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "9", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "1bfdc7ecebb0547bbe87a3696cddb0c0": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193483, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "50", + "connection/feeds-device-id": "evse-2", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", "info/name": "SPAN Drive - Driveway", "info/spaces": "35,37", "load-shed/priority": "OFF_GRID", @@ -33,49 +36,52 @@ "pcs/priority": "28", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "1eeeb748eeaa58edb7e9b7e9dbbdeca7": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Smoke Detectors\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193482, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Smoke Detectors\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Smoke Detectors", "info/spaces": "40", "load-shed/priority": "NEVER", - "meter/active-power": "-4.999832443194815", - "meter/current": "0.04166527035995679", + "meter/active-power": "-4.505989697917756", + "meter/current": "0.037549914149314634", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "21", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "2140a7e253ed54e3bc90a959081df615": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Refrigerator\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193481, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Refrigerator\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Refrigerator", "info/spaces": "15", "load-shed/priority": "NEVER", - "meter/active-power": "-108.45244558916716", - "meter/current": "0.9037703799097263", + "meter/active-power": "-104.5135692126635", + "meter/current": "0.8709464101055292", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "false", "pcs/priority": "14", "switch/relay": "CLOSED", "switch/relay-controllable": "false", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "249a2f59782e5f1ab317c4632e79afad": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193483, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "50", + "connection/feeds-device-id": "evse", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", "info/name": "SPAN Drive - Garage", "info/spaces": "32,34", "load-shed/priority": "OFF_GRID", @@ -87,64 +93,64 @@ "pcs/priority": "27", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "3d9d86f303cc50d1827be57d4c667e53": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193479, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Bedroom Lights", "info/spaces": "4", "load-shed/priority": "NEVER", - "meter/active-power": "-30.007200655746775", - "meter/current": "0.2500600054645565", + "meter/active-power": "-77.71804751120726", + "meter/current": "0.6476503959267271", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "3", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "3eeb0eb1605e5a7eadac41994b7a096c": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193479, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Master Bedroom Outlets", "info/spaces": "7", "load-shed/priority": "NEVER", - "meter/active-power": "-153.54536453511025", - "meter/current": "1.279544704459252", + "meter/active-power": "-146.27483088563423", + "meter/current": "1.218956924046952", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "6", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "43a0521737db516f99f14a9964ea4af0": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Washing Machine\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193481, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Washing Machine\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Washing Machine", "info/spaces": "17", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-934.9373635553584", - "meter/current": "7.791144696294653", + "meter/active-power": "0.0", + "meter/current": "0.0", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "16", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "4aeb08c46c2c5905a944166413f2f1ef": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garbage Disposal\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193482, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garbage Disposal\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", @@ -159,10 +165,10 @@ "pcs/priority": "19", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "4ce8b30e8d3f5c49b9e0ab0c8caf4832": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Water Heater\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193483, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Water Heater\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", @@ -177,208 +183,211 @@ "pcs/priority": "26", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "4d1deb6acb065746b13207b1358f8ca7": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Dishwasher\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193481, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Dishwasher\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Dishwasher", "info/spaces": "16", "load-shed/priority": "OFF_GRID", - "meter/active-power": "0.0", - "meter/current": "0.0", + "meter/active-power": "-1800.0", + "meter/current": "15.0", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "15", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "516694a326a35cd88600b3520e8a981a": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Pool Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193482, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Pool Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Pool Pump", "info/spaces": "39", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-245.4939312250361", - "meter/current": "2.0457827602086343", + "meter/active-power": "0.0", + "meter/current": "0.0", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "20", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "6fcb352679ad5bfb8c8a8eab06829b9f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193483, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", + "connection/feeds-device-id": "pv", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.pv", "info/name": "Solar Inverter", "info/spaces": "36,38", "load-shed/priority": "NEVER", - "meter/active-power": "3586.7736084560265", - "meter/current": "14.944890035233444", + "meter/active-power": "226.07117258699404", + "meter/current": "0.9419632191124752", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "false", "pcs/priority": "29", "switch/relay": "CLOSED", "switch/relay-controllable": "false", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "770e2de52c33508a8a9ee8878064b46f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193479, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Master Bedroom Lights", "info/spaces": "1", "load-shed/priority": "NEVER", - "meter/active-power": "-17.09164649643716", - "meter/current": "0.14243038747030967", + "meter/active-power": "-37.27458667650669", + "meter/current": "0.3106215556375558", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "1", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "80a4fada833156ab8112f9d50e252b8f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Counter)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193480, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Counter)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Kitchen Outlets (Counter)", "info/spaces": "9", "load-shed/priority": "NEVER", - "meter/active-power": "-257.52029126837186", - "meter/current": "2.146002427236432", + "meter/active-power": "-273.94820081512756", + "meter/current": "2.2829016734593965", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "8", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "9429f828509e58d59cb5f0f9f5fee523": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193479, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Living Room Lights", "info/spaces": "2", "load-shed/priority": "NEVER", - "meter/active-power": "-19.63772187888649", - "meter/current": "0.1636476823240541", + "meter/active-power": "-53.95340942682396", + "meter/current": "0.449611745223533", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "2", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "948dea7788aa5c959b99df0edfabead2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Heat Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193482, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Heat Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", "info/name": "Heat Pump", "info/spaces": "27,29", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-2067.125798359877", - "meter/current": "8.613024159832822", + "meter/active-power": "-2032.01215918081", + "meter/current": "8.46671732992004", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "24", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "af731c49a6785a4cb2ea5549fb8bce7e": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Main HVAC\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193482, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Main HVAC\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", "info/name": "Main HVAC", "info/spaces": "23,25", "load-shed/priority": "NEVER", - "meter/active-power": "-1063.0560418593357", - "meter/current": "4.429400174413899", + "meter/active-power": "-871.8737167912033", + "meter/current": "3.6328071532966804", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "23", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "afe90839f2725e3e962fb05afa2b6d43": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Chest Freezer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193481, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Chest Freezer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Chest Freezer", "info/spaces": "19", "load-shed/priority": "NEVER", - "meter/active-power": "-73.65733206593038", - "meter/current": "0.6138111005494198", + "meter/active-power": "-88.32342473910758", + "meter/current": "0.7360285394925632", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "false", "pcs/priority": "18", "switch/relay": "CLOSED", "switch/relay-controllable": "false", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "b24483358d29589d8e91d3bf11113269": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Office Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193480, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Office Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Office Outlets", "info/spaces": "11", "load-shed/priority": "NEVER", - "meter/active-power": "-316.9986735456939", - "meter/current": "2.6416556128807827", + "meter/active-power": "-286.66343376846635", + "meter/current": "2.388861948070553", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "10", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "b9fa08f1eaaf5d129bd5c78e1d5d937f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.circuit\", \"name\": \"kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193484, \"type\": \"energy.ebus.device.circuit\", \"name\": \"kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "kitchen Lights", "info/spaces": "3", "load-shed/priority": "NEVER", - "meter/active-power": "-135.18581560086707", - "meter/current": "1.126548463340559", + "meter/active-power": "-142.54557907902372", + "meter/current": "1.187879825658531", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "30", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "be7742043a06554aab2a1e38cc776603": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Oven/Range\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193483, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Oven/Range\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "40", @@ -393,92 +402,104 @@ "pcs/priority": "25", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "bess": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193484, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", + "info/model": "SPAN Battery", "info/nameplate-capacity": "13.5", + "info/part-number": "SPN-BESS-001", + "info/serial-number": "SIM-BESS-40T-001", "info/vendor-name": "Span", "meter/active-power": "3500.0", "soc/soc": "50.0", "soc/soe": "6.75", "status/communication-state": "OK" }, + "bess-mid": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193484, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"bess\", \"extensions\": []}", + "$state": "ready", + "grid/grid-forming-entity": "GRID", + "grid/grid-state": "UP", + "grid/islanding-state": "ON_GRID", + "info/serial-number": "SIM-BESS-40T-001-mid", + "info/vendor-name": "Span" + }, "c058aa11287f50f9b81e5160a0678869": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bathroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193479, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bathroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Bathroom Lights", "info/spaces": "5", "load-shed/priority": "NEVER", - "meter/active-power": "-13.23052838833021", - "meter/current": "0.11025440323608508", + "meter/active-power": "-30.98175549826103", + "meter/current": "0.2581812958188419", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "4", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "c339ec7ce7ff521ca7646f9606baff9f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Guest Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193481, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Guest Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Guest Room Outlets", "info/spaces": "14", "load-shed/priority": "NEVER", - "meter/active-power": "-128.97173258908555", - "meter/current": "1.0747644382423795", + "meter/active-power": "-161.6716994120287", + "meter/current": "1.3472641617669057", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "13", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "d1ff145887a05b839ede89409c27b398": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193480, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Garage Outlets", "info/spaces": "12", "load-shed/priority": "NEVER", - "meter/active-power": "-145.3516077123568", - "meter/current": "1.2112633976029734", + "meter/active-power": "-134.02830439940539", + "meter/current": "1.1169025366617116", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "11", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "e0ac90e169e6550ea83fe0b1942f1d0e": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193480, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Living Room Outlets", "info/spaces": "8", "load-shed/priority": "NEVER", - "meter/active-power": "-227.7013602466238", - "meter/current": "1.8975113353885318", + "meter/active-power": "-246.81992934282277", + "meter/current": "2.0568327445235233", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "7", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "e0bc156c85015a609d4132084dfcd6fe": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Microwave\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193481, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Microwave\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", @@ -493,50 +514,51 @@ "pcs/priority": "17", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "edee3425d50d51ffb022ee999053b2b4": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Laundry Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193480, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Laundry Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Laundry Room Outlets", "info/spaces": "13", "load-shed/priority": "NEVER", - "meter/active-power": "-145.1029999098799", - "meter/current": "1.2091916659156658", + "meter/active-power": "-158.846439136271", + "meter/current": "1.3237203261355917", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "12", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "ef972f063451539e8b2ad88e831d87b6": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Dryer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193482, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Dryer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", "info/name": "Electric Dryer", "info/spaces": "20,22", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-5000.0", - "meter/current": "20.833333333333332", + "meter/active-power": "0.0", + "meter/current": "0.0", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "22", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "evse": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193484, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", - "config/max-charge-current": "32.0", - "config/user-max-charge-current": "32.0", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", "info/firmware-version": "sim/v0.1.0", + "info/model": "SPAN Drive", "info/part-number": "SPN-DRV-001", "info/serial-number": "SIM-EVSE-sim-40t-001", "info/vendor-name": "SPAN", @@ -545,11 +567,12 @@ "switch/lock-state": "UNLOCKED" }, "evse-2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193484, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", - "config/max-charge-current": "32.0", - "config/user-max-charge-current": "32.0", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", "info/firmware-version": "sim/v0.1.0", + "info/model": "SPAN Drive", "info/part-number": "SPN-DRV-001", "info/serial-number": "SIM-EVSE-sim-40t-001-2", "info/vendor-name": "SPAN", @@ -558,50 +581,55 @@ "switch/lock-state": "UNLOCKED" }, "f515a0f43b6555b1a196fbb62728c24e": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Exterior Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193479, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Exterior Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Exterior Lights", "info/spaces": "6", "load-shed/priority": "OFF_GRID", - "meter/active-power": "0.0", - "meter/current": "0.0", + "meter/active-power": "-44.35131037211919", + "meter/current": "0.3695942531009932", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", "pcs/priority": "5", "switch/relay": "CLOSED", "switch/relay-controllable": "true", - "switch/relay-requester": "UNKNOWN" + "switch/relay-requester": "NONE" }, "lugs-downstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193484, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "info/direction": "DOWNSTREAM", - "meter/active-power": "18822.06352687105", - "meter/current-a": "108.41411763764835", - "meter/current-b": "108.21597189387751", + "meter/active-power": "17737.199152762874", + "meter/current-a": "63.20291953408804", + "meter/current-b": "88.37492628205247", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0" }, "lugs-upstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193484, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", + "connection/fed-by-device-id": "bess", + "connection/fed-by-device-status": "OK", + "connection/fed-by-device-type": "energy.ebus.device.bess", "info/direction": "UPSTREAM", - "meter/active-power": "18822.06352687105", - "meter/current-a": "108.41411763764835", - "meter/current-b": "108.21597189387751", + "meter/active-power": "17737.199152762874", + "meter/current-a": "63.20291953408804", + "meter/current-b": "88.37492628205247", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0" }, "pv": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940715, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193484, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", + "info/model": "IQ8PLUS-72-2-US", + "info/nominal-power": "10000.0", "info/vendor-name": "Enphase" }, "sim-40t-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786063940714, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Span Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"770e2de52c33508a8a9ee8878064b46f\", \"9429f828509e58d59cb5f0f9f5fee523\", \"3d9d86f303cc50d1827be57d4c667e53\", \"c058aa11287f50f9b81e5160a0678869\", \"f515a0f43b6555b1a196fbb62728c24e\", \"3eeb0eb1605e5a7eadac41994b7a096c\", \"e0ac90e169e6550ea83fe0b1942f1d0e\", \"80a4fada833156ab8112f9d50e252b8f\", \"13044bfbcbe5554b8f3dba126bce828f\", \"b24483358d29589d8e91d3bf11113269\", \"d1ff145887a05b839ede89409c27b398\", \"edee3425d50d51ffb022ee999053b2b4\", \"c339ec7ce7ff521ca7646f9606baff9f\", \"2140a7e253ed54e3bc90a959081df615\", \"4d1deb6acb065746b13207b1358f8ca7\", \"43a0521737db516f99f14a9964ea4af0\", \"e0bc156c85015a609d4132084dfcd6fe\", \"afe90839f2725e3e962fb05afa2b6d43\", \"4aeb08c46c2c5905a944166413f2f1ef\", \"516694a326a35cd88600b3520e8a981a\", \"1eeeb748eeaa58edb7e9b7e9dbbdeca7\", \"ef972f063451539e8b2ad88e831d87b6\", \"af731c49a6785a4cb2ea5549fb8bce7e\", \"948dea7788aa5c959b99df0edfabead2\", \"be7742043a06554aab2a1e38cc776603\", \"4ce8b30e8d3f5c49b9e0ab0c8caf4832\", \"249a2f59782e5f1ab317c4632e79afad\", \"1bfdc7ecebb0547bbe87a3696cddb0c0\", \"6fcb352679ad5bfb8c8a8eab06829b9f\", \"b9fa08f1eaaf5d129bd5c78e1d5d937f\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786157193484, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Span Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"770e2de52c33508a8a9ee8878064b46f\", \"9429f828509e58d59cb5f0f9f5fee523\", \"3d9d86f303cc50d1827be57d4c667e53\", \"c058aa11287f50f9b81e5160a0678869\", \"f515a0f43b6555b1a196fbb62728c24e\", \"3eeb0eb1605e5a7eadac41994b7a096c\", \"e0ac90e169e6550ea83fe0b1942f1d0e\", \"80a4fada833156ab8112f9d50e252b8f\", \"13044bfbcbe5554b8f3dba126bce828f\", \"b24483358d29589d8e91d3bf11113269\", \"d1ff145887a05b839ede89409c27b398\", \"edee3425d50d51ffb022ee999053b2b4\", \"c339ec7ce7ff521ca7646f9606baff9f\", \"2140a7e253ed54e3bc90a959081df615\", \"4d1deb6acb065746b13207b1358f8ca7\", \"43a0521737db516f99f14a9964ea4af0\", \"e0bc156c85015a609d4132084dfcd6fe\", \"afe90839f2725e3e962fb05afa2b6d43\", \"4aeb08c46c2c5905a944166413f2f1ef\", \"516694a326a35cd88600b3520e8a981a\", \"1eeeb748eeaa58edb7e9b7e9dbbdeca7\", \"ef972f063451539e8b2ad88e831d87b6\", \"af731c49a6785a4cb2ea5549fb8bce7e\", \"948dea7788aa5c959b99df0edfabead2\", \"be7742043a06554aab2a1e38cc776603\", \"4ce8b30e8d3f5c49b9e0ab0c8caf4832\", \"249a2f59782e5f1ab317c4632e79afad\", \"1bfdc7ecebb0547bbe87a3696cddb0c0\", \"6fcb352679ad5bfb8c8a8eab06829b9f\", \"b9fa08f1eaaf5d129bd5c78e1d5d937f\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", "$state": "ready", "breaker/rating": "200", "door/state": "CLOSED", @@ -630,10 +658,16 @@ "pcs/requested-import-limit-active": "false", "pcs/requested-import-limit-enablement": "UNCONFIGURED", "power-flows/battery": "3500.0", - "power-flows/grid": "15322.06352687105", - "power-flows/pv": "3586.7736084560265", - "power-flows/site": "22408.837135327078", + "power-flows/grid": "14237.199152762874", + "power-flows/pv": "226.07117258699404", + "power-flows/site": "17963.27032534987", + "shed-forecast/confidence": "HIGH", + "shed-forecast/full-charge-time-to-priority-shed": "3038", + "shed-forecast/full-charge-total-time-remaining": "4320", + "shed-forecast/time-to-priority-shed": "3037", + "shed-forecast/total-time-remaining": "4320", "shed/asserted-islanding-state": "NONE", + "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}", "status/cloud-connection": "CONNECTED", "status/ethernet": "true", "status/postal-code": "94103", diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json index ecdd663..b8b0188 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -12,9 +12,9 @@ "framework": "0.7", "peer": { "repo": "https://github.com/SpanPanel/panelbench", - "ref": "main", + "ref": "feat/adopt-upstream-emitter", "role": "publisher", - "commit": "266ff31b0fe4a7e515813201f6f0ac38c3cd1feb", + "commit": "43ec5b0ab296d3b6d0aacbe47318a25d63d20f05", "synced_commit": "4254526b0a8c11cab0d40fd700b1fc295c0479c6", "firmware_range": "r202633+", "fixtures": { diff --git a/tests/test_schema_one_against_simulator.py b/tests/test_schema_one_against_simulator.py index 625d645..2a594b8 100644 --- a/tests/test_schema_one_against_simulator.py +++ b/tests/test_schema_one_against_simulator.py @@ -89,31 +89,33 @@ def test_every_circuit_the_simulator_publishes_is_parsed(adapter: SchemaOneAdapt assert all(snapshot.circuits[circuit_id].name for circuit_id in real), "a circuit arrived with no name" -def test_the_ders_declare_a_model_they_never_publish(adapter: SchemaOneAdapter) -> None: - """A producer-side gap, pinned so it cannot fade into the background. - - Every DER the simulator publishes declares `info/model` in its - `$description` and never sends a value for it. PV is the widest: it declares - firmware-version, model, nominal-power, serial-number and vendor-name, and - publishes vendor-name alone. - - That breaks the one standing obligation eBus places on a publisher — be - self-describing, declare accurately what you publish — and it is the failure - mode this parser's `circuit_nodes_missing_names()` exists to surface: a - consumer waits on a value that is promised and never arrives, so the entity - is created and never updates. - - Worth knowing that panelbench's own conformance checker **cannot** catch - this. It compares declarations against catalogs, so a property declared and - never published is conformant by construction. Only a capture that carries - values can see it, which is the argument for this fixture existing. - - Pinned rather than asserted away: when the simulator publishes these, this - test fails and the expectation gets deleted. +def test_no_der_declares_a_property_it_never_publishes(adapter: SchemaOneAdapter) -> None: + """The producer-side gap that closed on 2026-08-08, held shut. + + This asserted `["bess", "pv", "evse", "evse-2"]` until the producer adopted + the upstream emitter and its DER metadata keys. Every one of those four + declared `info/model` in its `$description` and never sent a value; PV was the + widest, declaring firmware-version, model, nominal-power, serial-number and + vendor-name while publishing vendor-name alone. + + It breaks the one standing obligation eBus places on a publisher — declare + accurately what you publish — and the consumer symptom is specific: an entity + is created from the declaration, waits for a value that never arrives, and + never updates. That is why `circuit_nodes_missing_names()` exists. + + Worth keeping the note that panelbench's own conformance checker **cannot** + see this. It compares declarations against catalogs, so a property declared + and never published is conformant by construction. Only a capture carrying + values catches it, which remains the argument for this fixture. + + Now asserted empty rather than deleted. The list reaching zero is the + interesting state to defend: any DER that starts over-declaring again fails + here, named, instead of quietly recreating stale entities. """ - assert adapter.circuit_nodes_missing_names() == ["bess", "pv", "evse", "evse-2"], ( - "the set of devices declaring a model they never publish has changed. If the simulator " - "now publishes them, delete this test and assert circuit_nodes_missing_names() is empty." + assert adapter.circuit_nodes_missing_names() == [], ( + "these devices declare a property they never publish, which creates entities that " + "never update. This was empty as of the 2026-08-08 recapture, so it is a producer " + "regression rather than a known gap." ) @@ -143,19 +145,24 @@ def test_field_metadata_covers_what_the_snapshot_carries(adapter: SchemaOneAdapt ), "an abstract unit token reached field metadata; units must come from the device description" -def test_grid_state_is_absent_because_the_simulator_publishes_no_mid(adapter: SchemaOneAdapter) -> None: - """The one gap, asserted rather than left to be noticed. +def test_grid_state_is_read_from_the_mid(adapter: SchemaOneAdapter) -> None: + """The gap this used to pin, now closed and asserted from the other side. - `grid_state` reads the MID's `grid/islanding-state`. The simulator supports a - MID fully — profile, resolvers, snapshot field — but nothing instantiates one, - so no config produces it and this capture cannot exercise the mapping. + Until 2026-08-08 this test asserted `grid_state is None`, because the + simulator supported a MID fully — profile, resolvers, snapshot field — and no + config instantiated one, so the mapping had no evidence behind it. The + producer now publishes a MID and this reads a real value, so the expectation + inverts rather than disappears: the mapping is exercised, and going back to + `None` would be a regression, not a return to normal. - Pinned as an expectation so that the day the simulator does publish a MID, - this fails and says so, rather than the gap quietly persisting behind a - passing suite. Its counterpart is `_NOT_EXERCISED_BY_SIMULATOR` in - `test_schema_one_conformance.py`; both must be cleared together. + `ON_GRID` and not `UP` is the substance. The MID publishes both + `grid/islanding-state` (`ON_GRID`) and `grid/grid-state` (`UP`), and reading + the wrong one is precisely the defect corrected on 2026-08-06 — flat-schema + vocabulary sitting in a v1.0 property. Asserting the value proves which + property the reader reached, where asserting "not None" would pass either way. """ - assert adapter.build_snapshot().grid_state is None, ( - "the simulator now publishes a MID. Drop this test, and drop grid/islanding-state " - "from _NOT_EXERCISED_BY_SIMULATOR so the coverage check holds it instead." + assert adapter.build_snapshot().grid_state == "ON_GRID", ( + "grid_state must come from the MID's grid/islanding-state. 'UP' or 'DOWN' means " + "the reader has drifted onto grid/grid-state; None means the producer stopped " + "publishing a MID and the mapping is unexercised again." ) diff --git a/tests/test_schema_one_conformance.py b/tests/test_schema_one_conformance.py index 448cfac..5b4b1b9 100644 --- a/tests/test_schema_one_conformance.py +++ b/tests/test_schema_one_conformance.py @@ -205,13 +205,17 @@ def _simulator_declared() -> set[tuple[str, str]]: # Not defects on either side, but the precise list of what our development # producer does not exercise — which is exactly the part of the parser that gets # no evidence from testing against it. -_NOT_EXERCISED_BY_SIMULATOR: dict[tuple[str, str], str] = { - (const.NODE_GRID, PROP_ISLANDING_STATE): ( - "the simulator models a MID (wire/profiles/mid.json) but its tracked config publishes none, " - "so grid_state — corrected 2026-08-06 to read islanding-state rather than grid-state — is the " - "one mapping the producer gives no evidence for" - ), -} +# +# Empty as of 2026-08-08, and that is a measurement rather than a default. Its +# one entry was grid/islanding-state, excused because the simulator modelled a +# MID but no tracked config published one. The producer now publishes a MID, so +# the entry stopped being true and the check below said so. Every property this +# parser reads is now exercised by the capture it is developed against. +# +# The mechanism stays for the next gap. An empty dict is the honest state, and it +# is load-bearing: the coverage check now holds every mapping with nothing +# excused, so a future producer regression fails rather than lands here. +_NOT_EXERCISED_BY_SIMULATOR: dict[tuple[str, str], str] = {} # --------------------------------------------------------------------------- From 925a93ec44b47c0156796b5fb2acbe5f5a24cd56 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:06:47 -0700 Subject: [PATCH 045/115] test(schema_1): correct an over-claim, and pin the half of 5.2 still open My previous commit renamed the DER gap test to test_no_der_declares_a_property_it_never_publishes and asserted circuit_nodes_missing_names() == []. The assertion is right; the name is not. That function checks PROP_MODEL for DERs and PROP_NAME for circuits, so it measures the model gap specifically -- 'a property' claims the whole class and the empty result then reads as 'nothing is over-declared', which is false. Reading the capture directly, three declared properties still arrive with no value: bess info/firmware-version pv info/firmware-version, info/serial-number evse (none) evse-2 (none) So 5.2 is half closed, not closed. Adopting the upstream emitter's DER metadata keys fixed model everywhere and serial-number on the BESS and both EVSEs; PV still publishes vendor-name and nominal-power alone, and no DER publishes a firmware version. Scope restored on the first test, and the wider class pinned as an exact set in a second, so it fails in either direction -- a new over-declaration, or one of these finally published and the expectation due to shrink. Falsified by publishing bess info/firmware-version in the fixture: fails, restored. This decides the delta analysis's Class B, which is why it matters beyond tidiness. battery.serial_number is now unblocked and testable, because the BESS publishes info/serial-number. battery.software_version is not: it needs info/firmware-version, which is in the set above. 571 passed; ruff clean. --- tests/test_schema_one_against_simulator.py | 71 +++++++++++++++++----- 1 file changed, 55 insertions(+), 16 deletions(-) diff --git a/tests/test_schema_one_against_simulator.py b/tests/test_schema_one_against_simulator.py index 2a594b8..7f740c2 100644 --- a/tests/test_schema_one_against_simulator.py +++ b/tests/test_schema_one_against_simulator.py @@ -89,36 +89,75 @@ def test_every_circuit_the_simulator_publishes_is_parsed(adapter: SchemaOneAdapt assert all(snapshot.circuits[circuit_id].name for circuit_id in real), "a circuit arrived with no name" -def test_no_der_declares_a_property_it_never_publishes(adapter: SchemaOneAdapter) -> None: - """The producer-side gap that closed on 2026-08-08, held shut. +def test_no_der_declares_a_model_it_never_publishes(adapter: SchemaOneAdapter) -> None: + """The `info/model` half of the producer gap, closed on 2026-08-08. This asserted `["bess", "pv", "evse", "evse-2"]` until the producer adopted - the upstream emitter and its DER metadata keys. Every one of those four - declared `info/model` in its `$description` and never sent a value; PV was the - widest, declaring firmware-version, model, nominal-power, serial-number and - vendor-name while publishing vendor-name alone. + the upstream emitter and its DER metadata keys. All four declared + `info/model` and never sent a value. - It breaks the one standing obligation eBus places on a publisher — declare - accurately what you publish — and the consumer symptom is specific: an entity - is created from the declaration, waits for a value that never arrives, and - never updates. That is why `circuit_nodes_missing_names()` exists. + Scope is exactly `model`, because that is what `circuit_nodes_missing_names()` + measures for a DER — `PROP_MODEL` declared with no value, alongside circuits + missing `PROP_NAME`. The wider declared-but-unpublished question is + `test_the_ders_still_declare_two_identity_fields_they_never_publish` below, + which is not empty. + + The consumer symptom is specific: an entity is created from the declaration, + waits for a value that never arrives, and never updates. Worth keeping the note that panelbench's own conformance checker **cannot** see this. It compares declarations against catalogs, so a property declared and never published is conformant by construction. Only a capture carrying values catches it, which remains the argument for this fixture. - Now asserted empty rather than deleted. The list reaching zero is the - interesting state to defend: any DER that starts over-declaring again fails - here, named, instead of quietly recreating stale entities. + Asserted empty rather than deleted: zero is the state worth defending. """ assert adapter.circuit_nodes_missing_names() == [], ( - "these devices declare a property they never publish, which creates entities that " - "never update. This was empty as of the 2026-08-08 recapture, so it is a producer " - "regression rather than a known gap." + "these devices declare info/model and never publish it, which creates entities " + "that never update. This was empty as of the 2026-08-08 recapture, so it is a " + "producer regression rather than a known gap." ) +def test_the_ders_still_declare_two_identity_fields_they_never_publish() -> None: + """The rest of §5.2, which adopting the upstream emitter did *not* close. + + `circuit_nodes_missing_names()` looks only at `info/model`, so it reports + clean while three declared properties still arrive with no value. Reading the + capture directly is the only way to see the whole class, and leaving it + unmeasured would let "the model gap closed" read as "the gap closed". + + `battery.software_version` in the delta analysis's Class B depends on the BESS + firmware-version below, so that mapping stays untestable until this moves — + `battery.serial_number`, its Class B twin, is now unblocked because the BESS + does publish `info/serial-number`. + + Pinned as an exact set so it fails in either direction: a new over-declaration + appears, or one of these is finally published and the expectation should + shrink. + """ + with _WIRE.open() as handle: + wire = json.load(handle) + with (_WIRE.parent / "simulator_tree.json").open() as handle: + tree = json.load(handle) + + gaps = {} + for device in ("bess", "pv", "evse", "evse-2"): + declared = { + f"{node}/{prop}" + for node, body in (tree[device].get("nodes") or {}).items() + for prop in (body.get("properties") or {}) + } + published = {key for key in wire[device] if not key.startswith("$")} + if absent := sorted(declared - published): + gaps[device] = absent + + assert gaps == { + "bess": ["info/firmware-version"], + "pv": ["info/firmware-version", "info/serial-number"], + }, f"the declared-but-unpublished set moved: {gaps}" + + def test_the_fields_the_integration_consumes_are_populated(adapter: SchemaOneAdapter) -> None: """Presence, not values. A field left None reaches a user as an entity that exists and never updates, which is the failure this whole exercise is about. From 91d0741983a0bcfae015f23cd16966f640245fd6 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:52:36 -0700 Subject: [PATCH 046/115] test(migration): classify the flat -> v1.0 entity delta mechanically Phase 3a. Drives both adapters over a capture of the same logical panel and diffs which SpanPanelSnapshot fields each populates, so the three-bucket classification is produced rather than argued. The premise is checked, not assumed: serial sim-40t-001 on both sides, 30 configured circuits on both, and every circuit UUID identical across the two captures. That last one is the load-bearing fact for the whole migration -- unique_id is circuit-UUID-derived, so identical UUIDs mean the registry keeps the entity_id, statistic_id is unchanged, and long-term history survives. It now has a test instead of an argument. Result: circuits (32) 20 identity, 0 additions, 0 ORPHANS panel 35 identity, 0 additions, 2 orphans battery 5 identity, 3 additions, 0 orphans pv 3 identity, 1 addition, 1 orphan Three orphans total, and all three were already on the known-product-decision list: dominant_power_source, grid_islandable, pv.relative_position. So Phase 3's exit criterion -- zero unclassified orphans -- holds on the first run. Circuits are 96% of the entity surface and come through clean. The flat side is captured by substituting the transport inside start_clone rather than reassembling the emitter, so the manifest builder, BESS config, emitter and start() are all the production path and only the socket differs. Reassembling would have proved less than it appears to. The capture is shape-stable rather than byte-stable -- 53 of 559 topics move under noise_factor and an advancing clock -- which is sufficient because the classification reads population, never values. What the harness cannot vouch for is stated in the module docstring and pinned by two tests rather than left as prose, because a measured row and an unmeasured one look identical in a table. The flat simulator models the BESS and the Drives and the integration renders their telemetry correctly against it, so soc, soe, connected and the EVSE surface are attested. It publishes no BESS identity at all and no PV identity beyond vendor-name, so those four fields are classified as additions only because nothing sends them. battery.model is probably misclassified already: the eBus consumer guide has flat firmware publishing bess/model as the SKU, which would make it a semantic change -- the most dangerous class -- rather than a new field. A live flat panel cannot settle it either; the one available has no BESS and no Drives. Falsified by deleting status/cloud-connection from the v1.0 capture: fails with "panel.vendor_cloud ... nobody decided that". Restored, cmp-verified. --- scripts/capture_flat_reference.py | 130 +++++++ tests/fixtures/flat_wire.json | 563 +++++++++++++++++++++++++++ tests/test_schema_migration_delta.py | 277 +++++++++++++ 3 files changed, 970 insertions(+) create mode 100644 scripts/capture_flat_reference.py create mode 100644 tests/fixtures/flat_wire.json create mode 100644 tests/test_schema_migration_delta.py diff --git a/scripts/capture_flat_reference.py b/scripts/capture_flat_reference.py new file mode 100644 index 0000000..02bc660 --- /dev/null +++ b/scripts/capture_flat_reference.py @@ -0,0 +1,130 @@ +"""Capture the flat simulator's retained surface, without a broker. + +Produces `tests/fixtures/flat_wire.json`, the schema_0 side of the Phase 3 +classification. + +Run it from the **simulator's** environment, not this one — it imports the flat +emitter, whose `aiomqtt` dependency this repo does not carry: + + cd ../simulator + uv run python ../span-panel-api/scripts/capture_flat_reference.py \\ + ../span-panel-api/tests/fixtures/flat_wire.json + +`SIMULATOR_DIR` overrides where the checkout is looked for; it defaults to a +`simulator` directory beside this repo. + +Substitutes the transport rather than reassembling the emitter: `_AiomqttPublisher` +is swapped for a recorder and `start_clone` then runs its ordinary path — real +manifest builder, real BESS and load-shedding config, real Emitter, real +`start()`. Only the socket is different, which is the point of the capture. +Reassembling instead would prove less than it appears to, because a capture taken +through different wiring than a real panel uses is a capture of the wiring. + +The `mqttPublishFail` warnings this prints are the SDK's own redundant publish +path finding no paho client. Harmless: the lifecycle publishes `$state` and +`$description` through the injected transport, and both land in the capture. The +run asserts that rather than trusting it. + +The flat simulator is frozen at `v1.0.15 — the locked flat schema release`, so the +output is stable and vendored rather than re-taken. Re-run only if that changes. + +**Shape-stable, not byte-stable.** `noise_factor` and an advancing clock move 53 +of the 559 topics on every run; the device set and the topic set do not move at +all. That is enough, because the classification this feeds compares which fields +are *populated*, never their values — and it is the same property the +parent/child capture has, for the same reason. +""" + +import asyncio +import json +import os +import pathlib +import sys + +_REPO = pathlib.Path(__file__).resolve().parent.parent +SIM = pathlib.Path(os.environ.get("SIMULATOR_DIR", _REPO.parent / "simulator")) +if not (SIM / "src").is_dir(): + raise SystemExit(f"no simulator checkout at {SIM}; set SIMULATOR_DIR") +sys.path.insert(0, str(SIM / "src")) + +from span_panel_simulator.emitter_adapter import runtime as flat_runtime # noqa: E402 +from span_panel_simulator.engine import DynamicSimulationEngine # noqa: E402 + +CONFIG = SIM / "configs" / "default_MAIN_40.yaml" +OUT = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else pathlib.Path("flat_capture.json") + + +class RecordingPublisher: + """Satisfies the emitter's duck-typed MQTT interface, keeping last-wins state. + + Last-wins because that is what a broker's retained store holds, and therefore + what a consumer replays on connect. + """ + + def __init__(self, **kwargs: object) -> None: + self.retained: dict[str, bytes] = {} + self._kwargs = kwargs + LAST.append(self) + + async def connect(self) -> None: + return None + + async def disconnect(self) -> None: + return None + + def is_connected(self) -> bool: + return True + + async def publish( + self, topic: str, payload: bytes, qos: int = 0, retain: bool = False + ) -> None: + del qos + if retain: + self.retained[topic] = payload if isinstance(payload, bytes) else str(payload).encode() + + async def subscribe(self, topic: str) -> None: + del topic + return None + + +LAST: list[RecordingPublisher] = [] + + +def as_capture(retained: dict[str, bytes]) -> dict[str, dict[str, str]]: + """Regroup flat topics into the device-keyed shape a consumer sees.""" + devices: dict[str, dict[str, str]] = {} + for topic, payload in sorted(retained.items()): + parts = topic.split("/") + if len(parts) < 4: + continue + devices.setdefault(parts[2], {})["/".join(parts[3:])] = payload.decode() + return devices + + +async def main() -> None: + flat_runtime._AiomqttPublisher = RecordingPublisher # type: ignore[assignment] + + engine = DynamicSimulationEngine(config_path=CONFIG) + await engine.initialize_async() + + runtime = await flat_runtime.start_clone(engine) + await flat_runtime.publish_tick(runtime) + + recorder = LAST[-1] + capture = as_capture(recorder.retained) + + # The SDK's redundant publish path fails silently against no paho client, so + # check the two topics a consumer cannot reach ready without. + body = capture.get("sim-40t-001", {}) + missing = [key for key in ("$description", "$state") if key not in body] + if missing: + raise SystemExit(f"capture is unusable: {missing} never landed") + + OUT.write_text(json.dumps(capture, indent=2, sort_keys=True) + "\n") + + topics = sum(len(v) for v in capture.values()) + print(f"devices: {len(capture)} topics: {topics} $state={body['$state']!r}") + print("device ids:", sorted(capture)) + + +asyncio.run(main()) diff --git a/tests/fixtures/flat_wire.json b/tests/fixtures/flat_wire.json new file mode 100644 index 0000000..f711b35 --- /dev/null +++ b/tests/fixtures/flat_wire.json @@ -0,0 +1,563 @@ +{ + "sim-40t-001": { + "$description": "{\"homie\": \"5.0\", \"version\": 1, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Span Panel\", \"id\": \"sim-40t-001\", \"nodes\": {\"13044bfbcbe5554b8f3dba126bce828f\": {\"type\": \"energy.ebus.device.circuit\"}, \"1bfdc7ecebb0547bbe87a3696cddb0c0\": {\"type\": \"energy.ebus.device.circuit\"}, \"1eeeb748eeaa58edb7e9b7e9dbbdeca7\": {\"type\": \"energy.ebus.device.circuit\"}, \"2140a7e253ed54e3bc90a959081df615\": {\"type\": \"energy.ebus.device.circuit\"}, \"249a2f59782e5f1ab317c4632e79afad\": {\"type\": \"energy.ebus.device.circuit\"}, \"3d9d86f303cc50d1827be57d4c667e53\": {\"type\": \"energy.ebus.device.circuit\"}, \"3eeb0eb1605e5a7eadac41994b7a096c\": {\"type\": \"energy.ebus.device.circuit\"}, \"43a0521737db516f99f14a9964ea4af0\": {\"type\": \"energy.ebus.device.circuit\"}, \"4aeb08c46c2c5905a944166413f2f1ef\": {\"type\": \"energy.ebus.device.circuit\"}, \"4ce8b30e8d3f5c49b9e0ab0c8caf4832\": {\"type\": \"energy.ebus.device.circuit\"}, \"4d1deb6acb065746b13207b1358f8ca7\": {\"type\": \"energy.ebus.device.circuit\"}, \"516694a326a35cd88600b3520e8a981a\": {\"type\": \"energy.ebus.device.circuit\"}, \"6fcb352679ad5bfb8c8a8eab06829b9f\": {\"type\": \"energy.ebus.device.circuit\"}, \"770e2de52c33508a8a9ee8878064b46f\": {\"type\": \"energy.ebus.device.circuit\"}, \"80a4fada833156ab8112f9d50e252b8f\": {\"type\": \"energy.ebus.device.circuit\"}, \"9429f828509e58d59cb5f0f9f5fee523\": {\"type\": \"energy.ebus.device.circuit\"}, \"948dea7788aa5c959b99df0edfabead2\": {\"type\": \"energy.ebus.device.circuit\"}, \"af731c49a6785a4cb2ea5549fb8bce7e\": {\"type\": \"energy.ebus.device.circuit\"}, \"afe90839f2725e3e962fb05afa2b6d43\": {\"type\": \"energy.ebus.device.circuit\"}, \"b24483358d29589d8e91d3bf11113269\": {\"type\": \"energy.ebus.device.circuit\"}, \"b9fa08f1eaaf5d129bd5c78e1d5d937f\": {\"type\": \"energy.ebus.device.circuit\"}, \"be7742043a06554aab2a1e38cc776603\": {\"type\": \"energy.ebus.device.circuit\"}, \"bess\": {\"type\": \"energy.ebus.device.bess\"}, \"c058aa11287f50f9b81e5160a0678869\": {\"type\": \"energy.ebus.device.circuit\"}, \"c339ec7ce7ff521ca7646f9606baff9f\": {\"type\": \"energy.ebus.device.circuit\"}, \"core\": {\"type\": \"energy.ebus.device.distribution-enclosure.core\"}, \"d1ff145887a05b839ede89409c27b398\": {\"type\": \"energy.ebus.device.circuit\"}, \"e0ac90e169e6550ea83fe0b1942f1d0e\": {\"type\": \"energy.ebus.device.circuit\"}, \"e0bc156c85015a609d4132084dfcd6fe\": {\"type\": \"energy.ebus.device.circuit\"}, \"edee3425d50d51ffb022ee999053b2b4\": {\"type\": \"energy.ebus.device.circuit\"}, \"ef972f063451539e8b2ad88e831d87b6\": {\"type\": \"energy.ebus.device.circuit\"}, \"evse\": {\"type\": \"energy.ebus.device.evse\"}, \"evse-2\": {\"type\": \"energy.ebus.device.evse\"}, \"f515a0f43b6555b1a196fbb62728c24e\": {\"type\": \"energy.ebus.device.circuit\"}, \"lugs-downstream\": {\"type\": \"energy.ebus.device.lugs\"}, \"lugs-upstream\": {\"type\": \"energy.ebus.device.lugs\"}, \"pcs\": {\"type\": \"energy.ebus.device.pcs\"}, \"power-flows\": {\"type\": \"energy.ebus.device.power-flows\"}, \"pv\": {\"type\": \"energy.ebus.device.pv\"}}}", + "$state": "ready", + "13044bfbcbe5554b8f3dba126bce828f/active-power": "-306.9374003926985", + "13044bfbcbe5554b8f3dba126bce828f/always-on": "false", + "13044bfbcbe5554b8f3dba126bce828f/breaker-rating": "20", + "13044bfbcbe5554b8f3dba126bce828f/current": "2.5578116699391544", + "13044bfbcbe5554b8f3dba126bce828f/dipole": "false", + "13044bfbcbe5554b8f3dba126bce828f/exported-energy": "0.0", + "13044bfbcbe5554b8f3dba126bce828f/imported-energy": "0.0", + "13044bfbcbe5554b8f3dba126bce828f/name": "Kitchen Outlets (Island)", + "13044bfbcbe5554b8f3dba126bce828f/never-backup": "true", + "13044bfbcbe5554b8f3dba126bce828f/pcs-managed": "true", + "13044bfbcbe5554b8f3dba126bce828f/pcs-priority": "9", + "13044bfbcbe5554b8f3dba126bce828f/relay": "CLOSED", + "13044bfbcbe5554b8f3dba126bce828f/relay-requester": "NONE", + "13044bfbcbe5554b8f3dba126bce828f/shed-priority": "NEVER", + "13044bfbcbe5554b8f3dba126bce828f/sheddable": "false", + "13044bfbcbe5554b8f3dba126bce828f/space": "10", + "1bfdc7ecebb0547bbe87a3696cddb0c0/active-power": "0.0", + "1bfdc7ecebb0547bbe87a3696cddb0c0/always-on": "false", + "1bfdc7ecebb0547bbe87a3696cddb0c0/breaker-rating": "50", + "1bfdc7ecebb0547bbe87a3696cddb0c0/current": "0.0", + "1bfdc7ecebb0547bbe87a3696cddb0c0/dipole": "true", + "1bfdc7ecebb0547bbe87a3696cddb0c0/exported-energy": "0.0", + "1bfdc7ecebb0547bbe87a3696cddb0c0/imported-energy": "0.0", + "1bfdc7ecebb0547bbe87a3696cddb0c0/name": "SPAN Drive - Driveway", + "1bfdc7ecebb0547bbe87a3696cddb0c0/never-backup": "false", + "1bfdc7ecebb0547bbe87a3696cddb0c0/pcs-managed": "true", + "1bfdc7ecebb0547bbe87a3696cddb0c0/pcs-priority": "28", + "1bfdc7ecebb0547bbe87a3696cddb0c0/relay": "CLOSED", + "1bfdc7ecebb0547bbe87a3696cddb0c0/relay-requester": "NONE", + "1bfdc7ecebb0547bbe87a3696cddb0c0/shed-priority": "OFF_GRID", + "1bfdc7ecebb0547bbe87a3696cddb0c0/sheddable": "true", + "1bfdc7ecebb0547bbe87a3696cddb0c0/space": "35", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/active-power": "-5.424609262306141", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/always-on": "false", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/breaker-rating": "15", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/current": "0.045205077185884505", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/dipole": "false", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/exported-energy": "0.0", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/imported-energy": "0.0", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/name": "Smoke Detectors", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/never-backup": "true", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/pcs-managed": "true", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/pcs-priority": "21", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/relay": "CLOSED", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/relay-requester": "NONE", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/shed-priority": "NEVER", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/sheddable": "false", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/space": "40", + "2140a7e253ed54e3bc90a959081df615/active-power": "-136.64199310087642", + "2140a7e253ed54e3bc90a959081df615/always-on": "false", + "2140a7e253ed54e3bc90a959081df615/breaker-rating": "20", + "2140a7e253ed54e3bc90a959081df615/current": "1.1386832758406369", + "2140a7e253ed54e3bc90a959081df615/dipole": "false", + "2140a7e253ed54e3bc90a959081df615/exported-energy": "0.0", + "2140a7e253ed54e3bc90a959081df615/imported-energy": "0.0", + "2140a7e253ed54e3bc90a959081df615/name": "Refrigerator", + "2140a7e253ed54e3bc90a959081df615/never-backup": "true", + "2140a7e253ed54e3bc90a959081df615/pcs-managed": "false", + "2140a7e253ed54e3bc90a959081df615/pcs-priority": "14", + "2140a7e253ed54e3bc90a959081df615/relay": "CLOSED", + "2140a7e253ed54e3bc90a959081df615/relay-requester": "NONE", + "2140a7e253ed54e3bc90a959081df615/shed-priority": "NEVER", + "2140a7e253ed54e3bc90a959081df615/sheddable": "false", + "2140a7e253ed54e3bc90a959081df615/space": "15", + "249a2f59782e5f1ab317c4632e79afad/active-power": "0.0", + "249a2f59782e5f1ab317c4632e79afad/always-on": "false", + "249a2f59782e5f1ab317c4632e79afad/breaker-rating": "50", + "249a2f59782e5f1ab317c4632e79afad/current": "0.0", + "249a2f59782e5f1ab317c4632e79afad/dipole": "true", + "249a2f59782e5f1ab317c4632e79afad/exported-energy": "0.0", + "249a2f59782e5f1ab317c4632e79afad/imported-energy": "0.0", + "249a2f59782e5f1ab317c4632e79afad/name": "SPAN Drive - Garage", + "249a2f59782e5f1ab317c4632e79afad/never-backup": "false", + "249a2f59782e5f1ab317c4632e79afad/pcs-managed": "true", + "249a2f59782e5f1ab317c4632e79afad/pcs-priority": "27", + "249a2f59782e5f1ab317c4632e79afad/relay": "CLOSED", + "249a2f59782e5f1ab317c4632e79afad/relay-requester": "NONE", + "249a2f59782e5f1ab317c4632e79afad/shed-priority": "OFF_GRID", + "249a2f59782e5f1ab317c4632e79afad/sheddable": "true", + "249a2f59782e5f1ab317c4632e79afad/space": "32", + "3d9d86f303cc50d1827be57d4c667e53/active-power": "-8.491630065734094", + "3d9d86f303cc50d1827be57d4c667e53/always-on": "false", + "3d9d86f303cc50d1827be57d4c667e53/breaker-rating": "15", + "3d9d86f303cc50d1827be57d4c667e53/current": "0.07076358388111745", + "3d9d86f303cc50d1827be57d4c667e53/dipole": "false", + "3d9d86f303cc50d1827be57d4c667e53/exported-energy": "0.0", + "3d9d86f303cc50d1827be57d4c667e53/imported-energy": "0.0", + "3d9d86f303cc50d1827be57d4c667e53/name": "Bedroom Lights", + "3d9d86f303cc50d1827be57d4c667e53/never-backup": "true", + "3d9d86f303cc50d1827be57d4c667e53/pcs-managed": "true", + "3d9d86f303cc50d1827be57d4c667e53/pcs-priority": "3", + "3d9d86f303cc50d1827be57d4c667e53/relay": "CLOSED", + "3d9d86f303cc50d1827be57d4c667e53/relay-requester": "NONE", + "3d9d86f303cc50d1827be57d4c667e53/shed-priority": "NEVER", + "3d9d86f303cc50d1827be57d4c667e53/sheddable": "false", + "3d9d86f303cc50d1827be57d4c667e53/space": "4", + "3eeb0eb1605e5a7eadac41994b7a096c/active-power": "-162.67620727626297", + "3eeb0eb1605e5a7eadac41994b7a096c/always-on": "false", + "3eeb0eb1605e5a7eadac41994b7a096c/breaker-rating": "15", + "3eeb0eb1605e5a7eadac41994b7a096c/current": "1.3556350606355247", + "3eeb0eb1605e5a7eadac41994b7a096c/dipole": "false", + "3eeb0eb1605e5a7eadac41994b7a096c/exported-energy": "0.0", + "3eeb0eb1605e5a7eadac41994b7a096c/imported-energy": "0.0", + "3eeb0eb1605e5a7eadac41994b7a096c/name": "Master Bedroom Outlets", + "3eeb0eb1605e5a7eadac41994b7a096c/never-backup": "true", + "3eeb0eb1605e5a7eadac41994b7a096c/pcs-managed": "true", + "3eeb0eb1605e5a7eadac41994b7a096c/pcs-priority": "6", + "3eeb0eb1605e5a7eadac41994b7a096c/relay": "CLOSED", + "3eeb0eb1605e5a7eadac41994b7a096c/relay-requester": "NONE", + "3eeb0eb1605e5a7eadac41994b7a096c/shed-priority": "NEVER", + "3eeb0eb1605e5a7eadac41994b7a096c/sheddable": "false", + "3eeb0eb1605e5a7eadac41994b7a096c/space": "7", + "43a0521737db516f99f14a9964ea4af0/active-power": "0.0", + "43a0521737db516f99f14a9964ea4af0/always-on": "false", + "43a0521737db516f99f14a9964ea4af0/breaker-rating": "20", + "43a0521737db516f99f14a9964ea4af0/current": "0.0", + "43a0521737db516f99f14a9964ea4af0/dipole": "false", + "43a0521737db516f99f14a9964ea4af0/exported-energy": "0.0", + "43a0521737db516f99f14a9964ea4af0/imported-energy": "0.0", + "43a0521737db516f99f14a9964ea4af0/name": "Washing Machine", + "43a0521737db516f99f14a9964ea4af0/never-backup": "false", + "43a0521737db516f99f14a9964ea4af0/pcs-managed": "true", + "43a0521737db516f99f14a9964ea4af0/pcs-priority": "16", + "43a0521737db516f99f14a9964ea4af0/relay": "CLOSED", + "43a0521737db516f99f14a9964ea4af0/relay-requester": "NONE", + "43a0521737db516f99f14a9964ea4af0/shed-priority": "OFF_GRID", + "43a0521737db516f99f14a9964ea4af0/sheddable": "true", + "43a0521737db516f99f14a9964ea4af0/space": "17", + "4aeb08c46c2c5905a944166413f2f1ef/active-power": "0.0", + "4aeb08c46c2c5905a944166413f2f1ef/always-on": "false", + "4aeb08c46c2c5905a944166413f2f1ef/breaker-rating": "15", + "4aeb08c46c2c5905a944166413f2f1ef/current": "0.0", + "4aeb08c46c2c5905a944166413f2f1ef/dipole": "false", + "4aeb08c46c2c5905a944166413f2f1ef/exported-energy": "0.0", + "4aeb08c46c2c5905a944166413f2f1ef/imported-energy": "0.0", + "4aeb08c46c2c5905a944166413f2f1ef/name": "Garbage Disposal", + "4aeb08c46c2c5905a944166413f2f1ef/never-backup": "true", + "4aeb08c46c2c5905a944166413f2f1ef/pcs-managed": "true", + "4aeb08c46c2c5905a944166413f2f1ef/pcs-priority": "19", + "4aeb08c46c2c5905a944166413f2f1ef/relay": "CLOSED", + "4aeb08c46c2c5905a944166413f2f1ef/relay-requester": "NONE", + "4aeb08c46c2c5905a944166413f2f1ef/shed-priority": "NEVER", + "4aeb08c46c2c5905a944166413f2f1ef/sheddable": "false", + "4aeb08c46c2c5905a944166413f2f1ef/space": "21", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/active-power": "-2584.688456943218", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/always-on": "false", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/breaker-rating": "30", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/current": "10.769535237263408", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/dipole": "true", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/exported-energy": "0.0", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/imported-energy": "0.0", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/name": "Water Heater", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/never-backup": "false", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/pcs-managed": "true", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/pcs-priority": "26", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/relay": "CLOSED", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/relay-requester": "NONE", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/shed-priority": "OFF_GRID", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/sheddable": "true", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/space": "31", + "4d1deb6acb065746b13207b1358f8ca7/active-power": "0.0", + "4d1deb6acb065746b13207b1358f8ca7/always-on": "false", + "4d1deb6acb065746b13207b1358f8ca7/breaker-rating": "20", + "4d1deb6acb065746b13207b1358f8ca7/current": "0.0", + "4d1deb6acb065746b13207b1358f8ca7/dipole": "false", + "4d1deb6acb065746b13207b1358f8ca7/exported-energy": "0.0", + "4d1deb6acb065746b13207b1358f8ca7/imported-energy": "0.0", + "4d1deb6acb065746b13207b1358f8ca7/name": "Dishwasher", + "4d1deb6acb065746b13207b1358f8ca7/never-backup": "false", + "4d1deb6acb065746b13207b1358f8ca7/pcs-managed": "true", + "4d1deb6acb065746b13207b1358f8ca7/pcs-priority": "15", + "4d1deb6acb065746b13207b1358f8ca7/relay": "CLOSED", + "4d1deb6acb065746b13207b1358f8ca7/relay-requester": "NONE", + "4d1deb6acb065746b13207b1358f8ca7/shed-priority": "OFF_GRID", + "4d1deb6acb065746b13207b1358f8ca7/sheddable": "true", + "4d1deb6acb065746b13207b1358f8ca7/space": "16", + "516694a326a35cd88600b3520e8a981a/active-power": "-833.0787579160891", + "516694a326a35cd88600b3520e8a981a/always-on": "false", + "516694a326a35cd88600b3520e8a981a/breaker-rating": "20", + "516694a326a35cd88600b3520e8a981a/current": "6.942322982634076", + "516694a326a35cd88600b3520e8a981a/dipole": "false", + "516694a326a35cd88600b3520e8a981a/exported-energy": "0.0", + "516694a326a35cd88600b3520e8a981a/imported-energy": "0.0", + "516694a326a35cd88600b3520e8a981a/name": "Pool Pump", + "516694a326a35cd88600b3520e8a981a/never-backup": "false", + "516694a326a35cd88600b3520e8a981a/pcs-managed": "true", + "516694a326a35cd88600b3520e8a981a/pcs-priority": "20", + "516694a326a35cd88600b3520e8a981a/relay": "CLOSED", + "516694a326a35cd88600b3520e8a981a/relay-requester": "NONE", + "516694a326a35cd88600b3520e8a981a/shed-priority": "OFF_GRID", + "516694a326a35cd88600b3520e8a981a/sheddable": "true", + "516694a326a35cd88600b3520e8a981a/space": "39", + "6fcb352679ad5bfb8c8a8eab06829b9f/active-power": "5814.805477599427", + "6fcb352679ad5bfb8c8a8eab06829b9f/always-on": "false", + "6fcb352679ad5bfb8c8a8eab06829b9f/breaker-rating": "30", + "6fcb352679ad5bfb8c8a8eab06829b9f/current": "24.22835615666428", + "6fcb352679ad5bfb8c8a8eab06829b9f/dipole": "true", + "6fcb352679ad5bfb8c8a8eab06829b9f/exported-energy": "0.0", + "6fcb352679ad5bfb8c8a8eab06829b9f/imported-energy": "0.0", + "6fcb352679ad5bfb8c8a8eab06829b9f/name": "Solar Inverter", + "6fcb352679ad5bfb8c8a8eab06829b9f/never-backup": "true", + "6fcb352679ad5bfb8c8a8eab06829b9f/pcs-managed": "false", + "6fcb352679ad5bfb8c8a8eab06829b9f/pcs-priority": "29", + "6fcb352679ad5bfb8c8a8eab06829b9f/relay": "CLOSED", + "6fcb352679ad5bfb8c8a8eab06829b9f/relay-requester": "NONE", + "6fcb352679ad5bfb8c8a8eab06829b9f/shed-priority": "NEVER", + "6fcb352679ad5bfb8c8a8eab06829b9f/sheddable": "false", + "6fcb352679ad5bfb8c8a8eab06829b9f/space": "36", + "770e2de52c33508a8a9ee8878064b46f/active-power": "-3.8689398042961014", + "770e2de52c33508a8a9ee8878064b46f/always-on": "false", + "770e2de52c33508a8a9ee8878064b46f/breaker-rating": "15", + "770e2de52c33508a8a9ee8878064b46f/current": "0.032241165035800844", + "770e2de52c33508a8a9ee8878064b46f/dipole": "false", + "770e2de52c33508a8a9ee8878064b46f/exported-energy": "0.0", + "770e2de52c33508a8a9ee8878064b46f/imported-energy": "0.0", + "770e2de52c33508a8a9ee8878064b46f/name": "Master Bedroom Lights", + "770e2de52c33508a8a9ee8878064b46f/never-backup": "true", + "770e2de52c33508a8a9ee8878064b46f/pcs-managed": "true", + "770e2de52c33508a8a9ee8878064b46f/pcs-priority": "1", + "770e2de52c33508a8a9ee8878064b46f/relay": "CLOSED", + "770e2de52c33508a8a9ee8878064b46f/relay-requester": "NONE", + "770e2de52c33508a8a9ee8878064b46f/shed-priority": "NEVER", + "770e2de52c33508a8a9ee8878064b46f/sheddable": "false", + "770e2de52c33508a8a9ee8878064b46f/space": "1", + "80a4fada833156ab8112f9d50e252b8f/active-power": "-294.3991584148976", + "80a4fada833156ab8112f9d50e252b8f/always-on": "false", + "80a4fada833156ab8112f9d50e252b8f/breaker-rating": "20", + "80a4fada833156ab8112f9d50e252b8f/current": "2.453326320124147", + "80a4fada833156ab8112f9d50e252b8f/dipole": "false", + "80a4fada833156ab8112f9d50e252b8f/exported-energy": "0.0", + "80a4fada833156ab8112f9d50e252b8f/imported-energy": "0.0", + "80a4fada833156ab8112f9d50e252b8f/name": "Kitchen Outlets (Counter)", + "80a4fada833156ab8112f9d50e252b8f/never-backup": "true", + "80a4fada833156ab8112f9d50e252b8f/pcs-managed": "true", + "80a4fada833156ab8112f9d50e252b8f/pcs-priority": "8", + "80a4fada833156ab8112f9d50e252b8f/relay": "CLOSED", + "80a4fada833156ab8112f9d50e252b8f/relay-requester": "NONE", + "80a4fada833156ab8112f9d50e252b8f/shed-priority": "NEVER", + "80a4fada833156ab8112f9d50e252b8f/sheddable": "false", + "80a4fada833156ab8112f9d50e252b8f/space": "9", + "9429f828509e58d59cb5f0f9f5fee523/active-power": "-4.54538605930548", + "9429f828509e58d59cb5f0f9f5fee523/always-on": "false", + "9429f828509e58d59cb5f0f9f5fee523/breaker-rating": "15", + "9429f828509e58d59cb5f0f9f5fee523/current": "0.037878217160879", + "9429f828509e58d59cb5f0f9f5fee523/dipole": "false", + "9429f828509e58d59cb5f0f9f5fee523/exported-energy": "0.0", + "9429f828509e58d59cb5f0f9f5fee523/imported-energy": "0.0", + "9429f828509e58d59cb5f0f9f5fee523/name": "Living Room Lights", + "9429f828509e58d59cb5f0f9f5fee523/never-backup": "true", + "9429f828509e58d59cb5f0f9f5fee523/pcs-managed": "true", + "9429f828509e58d59cb5f0f9f5fee523/pcs-priority": "2", + "9429f828509e58d59cb5f0f9f5fee523/relay": "CLOSED", + "9429f828509e58d59cb5f0f9f5fee523/relay-requester": "NONE", + "9429f828509e58d59cb5f0f9f5fee523/shed-priority": "NEVER", + "9429f828509e58d59cb5f0f9f5fee523/sheddable": "false", + "9429f828509e58d59cb5f0f9f5fee523/space": "2", + "948dea7788aa5c959b99df0edfabead2/active-power": "-2104.816174600588", + "948dea7788aa5c959b99df0edfabead2/always-on": "false", + "948dea7788aa5c959b99df0edfabead2/breaker-rating": "30", + "948dea7788aa5c959b99df0edfabead2/current": "8.770067394169116", + "948dea7788aa5c959b99df0edfabead2/dipole": "true", + "948dea7788aa5c959b99df0edfabead2/exported-energy": "0.0", + "948dea7788aa5c959b99df0edfabead2/imported-energy": "0.0", + "948dea7788aa5c959b99df0edfabead2/name": "Heat Pump", + "948dea7788aa5c959b99df0edfabead2/never-backup": "false", + "948dea7788aa5c959b99df0edfabead2/pcs-managed": "true", + "948dea7788aa5c959b99df0edfabead2/pcs-priority": "24", + "948dea7788aa5c959b99df0edfabead2/relay": "CLOSED", + "948dea7788aa5c959b99df0edfabead2/relay-requester": "NONE", + "948dea7788aa5c959b99df0edfabead2/shed-priority": "OFF_GRID", + "948dea7788aa5c959b99df0edfabead2/sheddable": "true", + "948dea7788aa5c959b99df0edfabead2/space": "27", + "af731c49a6785a4cb2ea5549fb8bce7e/active-power": "-644.9195219741802", + "af731c49a6785a4cb2ea5549fb8bce7e/always-on": "false", + "af731c49a6785a4cb2ea5549fb8bce7e/breaker-rating": "30", + "af731c49a6785a4cb2ea5549fb8bce7e/current": "2.687164674892417", + "af731c49a6785a4cb2ea5549fb8bce7e/dipole": "true", + "af731c49a6785a4cb2ea5549fb8bce7e/exported-energy": "0.0", + "af731c49a6785a4cb2ea5549fb8bce7e/imported-energy": "0.0", + "af731c49a6785a4cb2ea5549fb8bce7e/name": "Main HVAC", + "af731c49a6785a4cb2ea5549fb8bce7e/never-backup": "true", + "af731c49a6785a4cb2ea5549fb8bce7e/pcs-managed": "true", + "af731c49a6785a4cb2ea5549fb8bce7e/pcs-priority": "23", + "af731c49a6785a4cb2ea5549fb8bce7e/relay": "CLOSED", + "af731c49a6785a4cb2ea5549fb8bce7e/relay-requester": "NONE", + "af731c49a6785a4cb2ea5549fb8bce7e/shed-priority": "NEVER", + "af731c49a6785a4cb2ea5549fb8bce7e/sheddable": "false", + "af731c49a6785a4cb2ea5549fb8bce7e/space": "23", + "afe90839f2725e3e962fb05afa2b6d43/active-power": "-69.85816854519369", + "afe90839f2725e3e962fb05afa2b6d43/always-on": "false", + "afe90839f2725e3e962fb05afa2b6d43/breaker-rating": "20", + "afe90839f2725e3e962fb05afa2b6d43/current": "0.5821514045432807", + "afe90839f2725e3e962fb05afa2b6d43/dipole": "false", + "afe90839f2725e3e962fb05afa2b6d43/exported-energy": "0.0", + "afe90839f2725e3e962fb05afa2b6d43/imported-energy": "0.0", + "afe90839f2725e3e962fb05afa2b6d43/name": "Chest Freezer", + "afe90839f2725e3e962fb05afa2b6d43/never-backup": "true", + "afe90839f2725e3e962fb05afa2b6d43/pcs-managed": "false", + "afe90839f2725e3e962fb05afa2b6d43/pcs-priority": "18", + "afe90839f2725e3e962fb05afa2b6d43/relay": "CLOSED", + "afe90839f2725e3e962fb05afa2b6d43/relay-requester": "NONE", + "afe90839f2725e3e962fb05afa2b6d43/shed-priority": "NEVER", + "afe90839f2725e3e962fb05afa2b6d43/sheddable": "false", + "afe90839f2725e3e962fb05afa2b6d43/space": "19", + "b24483358d29589d8e91d3bf11113269/active-power": "-267.7799960113091", + "b24483358d29589d8e91d3bf11113269/always-on": "false", + "b24483358d29589d8e91d3bf11113269/breaker-rating": "15", + "b24483358d29589d8e91d3bf11113269/current": "2.231499966760909", + "b24483358d29589d8e91d3bf11113269/dipole": "false", + "b24483358d29589d8e91d3bf11113269/exported-energy": "0.0", + "b24483358d29589d8e91d3bf11113269/imported-energy": "0.0", + "b24483358d29589d8e91d3bf11113269/name": "Office Outlets", + "b24483358d29589d8e91d3bf11113269/never-backup": "true", + "b24483358d29589d8e91d3bf11113269/pcs-managed": "true", + "b24483358d29589d8e91d3bf11113269/pcs-priority": "10", + "b24483358d29589d8e91d3bf11113269/relay": "CLOSED", + "b24483358d29589d8e91d3bf11113269/relay-requester": "NONE", + "b24483358d29589d8e91d3bf11113269/shed-priority": "NEVER", + "b24483358d29589d8e91d3bf11113269/sheddable": "false", + "b24483358d29589d8e91d3bf11113269/space": "11", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/active-power": "-128.18310004021967", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/always-on": "false", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/breaker-rating": "15", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/current": "1.068192500335164", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/dipole": "false", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/exported-energy": "0.0", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/imported-energy": "0.0", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/name": "kitchen Lights", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/never-backup": "true", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/pcs-managed": "true", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/pcs-priority": "30", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/relay": "CLOSED", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/relay-requester": "NONE", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/shed-priority": "NEVER", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/sheddable": "false", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/space": "3", + "be7742043a06554aab2a1e38cc776603/active-power": "-4570.0202514924495", + "be7742043a06554aab2a1e38cc776603/always-on": "false", + "be7742043a06554aab2a1e38cc776603/breaker-rating": "40", + "be7742043a06554aab2a1e38cc776603/current": "19.041751047885207", + "be7742043a06554aab2a1e38cc776603/dipole": "true", + "be7742043a06554aab2a1e38cc776603/exported-energy": "0.0", + "be7742043a06554aab2a1e38cc776603/imported-energy": "0.0", + "be7742043a06554aab2a1e38cc776603/name": "Electric Oven/Range", + "be7742043a06554aab2a1e38cc776603/never-backup": "false", + "be7742043a06554aab2a1e38cc776603/pcs-managed": "true", + "be7742043a06554aab2a1e38cc776603/pcs-priority": "25", + "be7742043a06554aab2a1e38cc776603/relay": "CLOSED", + "be7742043a06554aab2a1e38cc776603/relay-requester": "NONE", + "be7742043a06554aab2a1e38cc776603/shed-priority": "OFF_GRID", + "be7742043a06554aab2a1e38cc776603/sheddable": "true", + "be7742043a06554aab2a1e38cc776603/space": "28", + "bess/connected": "true", + "bess/grid-state": "ON_GRID", + "bess/nameplate-capacity": "13.5", + "bess/relative-position": "UPSTREAM", + "bess/soc": "50.0", + "bess/soe": "6.75", + "bess/vendor-name": "Span", + "c058aa11287f50f9b81e5160a0678869/active-power": "-2.7604458634368765", + "c058aa11287f50f9b81e5160a0678869/always-on": "false", + "c058aa11287f50f9b81e5160a0678869/breaker-rating": "15", + "c058aa11287f50f9b81e5160a0678869/current": "0.023003715528640636", + "c058aa11287f50f9b81e5160a0678869/dipole": "false", + "c058aa11287f50f9b81e5160a0678869/exported-energy": "0.0", + "c058aa11287f50f9b81e5160a0678869/imported-energy": "0.0", + "c058aa11287f50f9b81e5160a0678869/name": "Bathroom Lights", + "c058aa11287f50f9b81e5160a0678869/never-backup": "true", + "c058aa11287f50f9b81e5160a0678869/pcs-managed": "true", + "c058aa11287f50f9b81e5160a0678869/pcs-priority": "4", + "c058aa11287f50f9b81e5160a0678869/relay": "CLOSED", + "c058aa11287f50f9b81e5160a0678869/relay-requester": "NONE", + "c058aa11287f50f9b81e5160a0678869/shed-priority": "NEVER", + "c058aa11287f50f9b81e5160a0678869/sheddable": "false", + "c058aa11287f50f9b81e5160a0678869/space": "5", + "c339ec7ce7ff521ca7646f9606baff9f/active-power": "-170.30599501587994", + "c339ec7ce7ff521ca7646f9606baff9f/always-on": "false", + "c339ec7ce7ff521ca7646f9606baff9f/breaker-rating": "15", + "c339ec7ce7ff521ca7646f9606baff9f/current": "1.4192166251323328", + "c339ec7ce7ff521ca7646f9606baff9f/dipole": "false", + "c339ec7ce7ff521ca7646f9606baff9f/exported-energy": "0.0", + "c339ec7ce7ff521ca7646f9606baff9f/imported-energy": "0.0", + "c339ec7ce7ff521ca7646f9606baff9f/name": "Guest Room Outlets", + "c339ec7ce7ff521ca7646f9606baff9f/never-backup": "true", + "c339ec7ce7ff521ca7646f9606baff9f/pcs-managed": "true", + "c339ec7ce7ff521ca7646f9606baff9f/pcs-priority": "13", + "c339ec7ce7ff521ca7646f9606baff9f/relay": "CLOSED", + "c339ec7ce7ff521ca7646f9606baff9f/relay-requester": "NONE", + "c339ec7ce7ff521ca7646f9606baff9f/shed-priority": "NEVER", + "c339ec7ce7ff521ca7646f9606baff9f/sheddable": "false", + "c339ec7ce7ff521ca7646f9606baff9f/space": "14", + "core/breaker-rating": "200", + "core/dominant-power-source": "GRID", + "core/door": "CLOSED", + "core/ethernet": "true", + "core/grid-islandable": "false", + "core/hardware-version": "rev2", + "core/l1-voltage": "120.0", + "core/l2-voltage": "120.0", + "core/model": "MAIN_40", + "core/postal-code": "94103", + "core/relay": "CLOSED", + "core/serial-number": "sim-40t-001", + "core/software-version": "sim/v0.1.0", + "core/time-zone": "America/Los_Angeles", + "core/vendor-cloud": "CONNECTED", + "core/vendor-name": "Span", + "core/wifi": "true", + "d1ff145887a05b839ede89409c27b398/active-power": "-136.31325580966814", + "d1ff145887a05b839ede89409c27b398/always-on": "false", + "d1ff145887a05b839ede89409c27b398/breaker-rating": "15", + "d1ff145887a05b839ede89409c27b398/current": "1.1359437984139011", + "d1ff145887a05b839ede89409c27b398/dipole": "false", + "d1ff145887a05b839ede89409c27b398/exported-energy": "0.0", + "d1ff145887a05b839ede89409c27b398/imported-energy": "0.0", + "d1ff145887a05b839ede89409c27b398/name": "Garage Outlets", + "d1ff145887a05b839ede89409c27b398/never-backup": "true", + "d1ff145887a05b839ede89409c27b398/pcs-managed": "true", + "d1ff145887a05b839ede89409c27b398/pcs-priority": "11", + "d1ff145887a05b839ede89409c27b398/relay": "CLOSED", + "d1ff145887a05b839ede89409c27b398/relay-requester": "NONE", + "d1ff145887a05b839ede89409c27b398/shed-priority": "NEVER", + "d1ff145887a05b839ede89409c27b398/sheddable": "false", + "d1ff145887a05b839ede89409c27b398/space": "12", + "e0ac90e169e6550ea83fe0b1942f1d0e/active-power": "-219.7446532988136", + "e0ac90e169e6550ea83fe0b1942f1d0e/always-on": "false", + "e0ac90e169e6550ea83fe0b1942f1d0e/breaker-rating": "15", + "e0ac90e169e6550ea83fe0b1942f1d0e/current": "1.83120544415678", + "e0ac90e169e6550ea83fe0b1942f1d0e/dipole": "false", + "e0ac90e169e6550ea83fe0b1942f1d0e/exported-energy": "0.0", + "e0ac90e169e6550ea83fe0b1942f1d0e/imported-energy": "0.0", + "e0ac90e169e6550ea83fe0b1942f1d0e/name": "Living Room Outlets", + "e0ac90e169e6550ea83fe0b1942f1d0e/never-backup": "true", + "e0ac90e169e6550ea83fe0b1942f1d0e/pcs-managed": "true", + "e0ac90e169e6550ea83fe0b1942f1d0e/pcs-priority": "7", + "e0ac90e169e6550ea83fe0b1942f1d0e/relay": "CLOSED", + "e0ac90e169e6550ea83fe0b1942f1d0e/relay-requester": "NONE", + "e0ac90e169e6550ea83fe0b1942f1d0e/shed-priority": "NEVER", + "e0ac90e169e6550ea83fe0b1942f1d0e/sheddable": "false", + "e0ac90e169e6550ea83fe0b1942f1d0e/space": "8", + "e0bc156c85015a609d4132084dfcd6fe/active-power": "-1500.0", + "e0bc156c85015a609d4132084dfcd6fe/always-on": "false", + "e0bc156c85015a609d4132084dfcd6fe/breaker-rating": "20", + "e0bc156c85015a609d4132084dfcd6fe/current": "12.5", + "e0bc156c85015a609d4132084dfcd6fe/dipole": "false", + "e0bc156c85015a609d4132084dfcd6fe/exported-energy": "0.0", + "e0bc156c85015a609d4132084dfcd6fe/imported-energy": "0.0", + "e0bc156c85015a609d4132084dfcd6fe/name": "Microwave", + "e0bc156c85015a609d4132084dfcd6fe/never-backup": "true", + "e0bc156c85015a609d4132084dfcd6fe/pcs-managed": "true", + "e0bc156c85015a609d4132084dfcd6fe/pcs-priority": "17", + "e0bc156c85015a609d4132084dfcd6fe/relay": "CLOSED", + "e0bc156c85015a609d4132084dfcd6fe/relay-requester": "NONE", + "e0bc156c85015a609d4132084dfcd6fe/shed-priority": "NEVER", + "e0bc156c85015a609d4132084dfcd6fe/sheddable": "false", + "e0bc156c85015a609d4132084dfcd6fe/space": "18", + "edee3425d50d51ffb022ee999053b2b4/active-power": "-155.86726575300366", + "edee3425d50d51ffb022ee999053b2b4/always-on": "false", + "edee3425d50d51ffb022ee999053b2b4/breaker-rating": "15", + "edee3425d50d51ffb022ee999053b2b4/current": "1.2988938812750306", + "edee3425d50d51ffb022ee999053b2b4/dipole": "false", + "edee3425d50d51ffb022ee999053b2b4/exported-energy": "0.0", + "edee3425d50d51ffb022ee999053b2b4/imported-energy": "0.0", + "edee3425d50d51ffb022ee999053b2b4/name": "Laundry Room Outlets", + "edee3425d50d51ffb022ee999053b2b4/never-backup": "true", + "edee3425d50d51ffb022ee999053b2b4/pcs-managed": "true", + "edee3425d50d51ffb022ee999053b2b4/pcs-priority": "12", + "edee3425d50d51ffb022ee999053b2b4/relay": "CLOSED", + "edee3425d50d51ffb022ee999053b2b4/relay-requester": "NONE", + "edee3425d50d51ffb022ee999053b2b4/shed-priority": "NEVER", + "edee3425d50d51ffb022ee999053b2b4/sheddable": "false", + "edee3425d50d51ffb022ee999053b2b4/space": "13", + "ef972f063451539e8b2ad88e831d87b6/active-power": "0.0", + "ef972f063451539e8b2ad88e831d87b6/always-on": "false", + "ef972f063451539e8b2ad88e831d87b6/breaker-rating": "30", + "ef972f063451539e8b2ad88e831d87b6/current": "0.0", + "ef972f063451539e8b2ad88e831d87b6/dipole": "true", + "ef972f063451539e8b2ad88e831d87b6/exported-energy": "0.0", + "ef972f063451539e8b2ad88e831d87b6/imported-energy": "0.0", + "ef972f063451539e8b2ad88e831d87b6/name": "Electric Dryer", + "ef972f063451539e8b2ad88e831d87b6/never-backup": "false", + "ef972f063451539e8b2ad88e831d87b6/pcs-managed": "true", + "ef972f063451539e8b2ad88e831d87b6/pcs-priority": "22", + "ef972f063451539e8b2ad88e831d87b6/relay": "CLOSED", + "ef972f063451539e8b2ad88e831d87b6/relay-requester": "NONE", + "ef972f063451539e8b2ad88e831d87b6/shed-priority": "OFF_GRID", + "ef972f063451539e8b2ad88e831d87b6/sheddable": "true", + "ef972f063451539e8b2ad88e831d87b6/space": "20", + "evse-2/advertised-current": "32.0", + "evse-2/feed": "1bfdc7ecebb0547bbe87a3696cddb0c0", + "evse-2/lock-state": "UNLOCKED", + "evse-2/part-number": "SPN-DRV-001", + "evse-2/product-name": "SPAN Drive", + "evse-2/serial-number": "SIM-EVSE-sim-40t-001-2", + "evse-2/software-version": "sim/v0.1.0", + "evse-2/status": "AVAILABLE", + "evse-2/vendor-name": "SPAN", + "evse/advertised-current": "32.0", + "evse/feed": "249a2f59782e5f1ab317c4632e79afad", + "evse/lock-state": "UNLOCKED", + "evse/part-number": "SPN-DRV-001", + "evse/product-name": "SPAN Drive", + "evse/serial-number": "SIM-EVSE-sim-40t-001", + "evse/software-version": "sim/v0.1.0", + "evse/status": "AVAILABLE", + "evse/vendor-name": "SPAN", + "f515a0f43b6555b1a196fbb62728c24e/active-power": "0.0", + "f515a0f43b6555b1a196fbb62728c24e/always-on": "false", + "f515a0f43b6555b1a196fbb62728c24e/breaker-rating": "15", + "f515a0f43b6555b1a196fbb62728c24e/current": "0.0", + "f515a0f43b6555b1a196fbb62728c24e/dipole": "false", + "f515a0f43b6555b1a196fbb62728c24e/exported-energy": "0.0", + "f515a0f43b6555b1a196fbb62728c24e/imported-energy": "0.0", + "f515a0f43b6555b1a196fbb62728c24e/name": "Exterior Lights", + "f515a0f43b6555b1a196fbb62728c24e/never-backup": "false", + "f515a0f43b6555b1a196fbb62728c24e/pcs-managed": "true", + "f515a0f43b6555b1a196fbb62728c24e/pcs-priority": "5", + "f515a0f43b6555b1a196fbb62728c24e/relay": "CLOSED", + "f515a0f43b6555b1a196fbb62728c24e/relay-requester": "NONE", + "f515a0f43b6555b1a196fbb62728c24e/shed-priority": "OFF_GRID", + "f515a0f43b6555b1a196fbb62728c24e/sheddable": "true", + "f515a0f43b6555b1a196fbb62728c24e/space": "6", + "lugs-downstream/active-power": "8496.515890041", + "lugs-downstream/direction": "DOWNSTREAM", + "lugs-downstream/exported-energy": "0.0", + "lugs-downstream/imported-energy": "0.0", + "lugs-downstream/l1-current": "82.62282478358763", + "lugs-downstream/l2-current": "85.09489892674448", + "lugs-upstream/active-power": "8496.515890040999", + "lugs-upstream/direction": "UPSTREAM", + "lugs-upstream/exported-energy": "0.0", + "lugs-upstream/imported-energy": "0.0", + "lugs-upstream/l1-current": "82.62282478358763", + "lugs-upstream/l2-current": "85.09489892674448", + "pcs/active": "false", + "pcs/enabled": "false", + "pcs/feed-import-limit": "0.0", + "pcs/feed-import-limit-active": "false", + "pcs/feed-import-limit-enablement": "UNCONFIGURED", + "pcs/grid-import-limit": "0.0", + "pcs/grid-import-limit-active": "false", + "pcs/grid-import-limit-enablement": "UNCONFIGURED", + "pcs/import-limit": "0.0", + "pcs/off-grid-import-limit": "0.0", + "pcs/off-grid-import-limit-active": "false", + "pcs/off-grid-import-limit-enablement": "UNCONFIGURED", + "pcs/requested-import-limit": "0.0", + "pcs/requested-import-limit-active": "false", + "pcs/requested-import-limit-enablement": "UNCONFIGURED", + "power-flows/battery": "3500.0", + "power-flows/grid": "4996.515890040999", + "power-flows/pv": "5814.805477599427", + "power-flows/site": "14311.321367640427", + "pv/feed": "6fcb352679ad5bfb8c8a8eab06829b9f", + "pv/nameplate-capacity": "10000.0", + "pv/relative-position": "IN_PANEL", + "pv/vendor-name": "Enphase" + } +} diff --git a/tests/test_schema_migration_delta.py b/tests/test_schema_migration_delta.py new file mode 100644 index 0000000..2d3620c --- /dev/null +++ b/tests/test_schema_migration_delta.py @@ -0,0 +1,277 @@ +"""Phase 3: what happens to a user's entities when firmware moves flat → v1.0. + +The acceptance criterion is that a user upgrades and **nothing in their Home +Assistant changes**. This produces the classification mechanically rather than by +argument: drive both adapters over a capture of the *same logical panel*, and +diff which `SpanPanelSnapshot` fields each populates. + +Same panel is not a claim, it is checked below — serial `sim-40t-001`, 30 +configured circuits, and every circuit UUID identical across both captures. +That last one is the load-bearing fact for entity survival: `unique_id` is +circuit-UUID-derived, so identical UUIDs mean the registry keeps the same +`entity_id`, which means `statistic_id` is unchanged and long-term history +survives. + +**Population, not values.** The two captures are different runs of different +simulators, so values cannot match and asserting them would be noise. What +matters is whether a field a user has today still arrives tomorrow. + +Three buckets: + +- **identity** — populated on both sides. The entity survives unremarked. +- **addition** — v1.0 only. New; a product decision about whether to surface it, + never a migration risk. +- **orphan** — flat only. **The dangerous bucket.** An entity that exists today + and stops updating, which HA shows as stale rather than gone. + +An orphan not on `EXPECTED_ORPHANS` fails. That is the whole point: the list is +short, every member is a decision someone made on purpose, and anything else is a +regression that reached a user. + +--- + +**What this cannot tell you, which matters as much as what it can.** + +The flat side is the frozen simulator, a proxy for flat firmware rather than +firmware itself. The gap is narrower than "DER is unverified", and worth stating +precisely, because the two halves have very different support. + +*Telemetry is attested.* The simulator models the BESS and the Drives, and the +integration renders their entities correctly against it — which is real evidence +for `soc`, `soe`, `connected`, `nameplate-capacity`, `relative-position` and the +EVSE surface, all of which it publishes. + +*Identity is not published at all*, so nothing can attest it: + +| device | identity keys the flat simulator publishes | +| --- | --- | +| panel | `model`, `serial-number`, `software-version` | +| BESS | **none** | +| PV | `vendor-name` only | +| EVSE | full | + +`PROVISIONAL_DER` is exactly that unpublished set — not a hedge across DER +generally. And one member is probably misclassified already: `battery.model` +reads as an addition here, while the eBus consumer guide has flat firmware +publishing `bess/model` as the SKU, which would make it a *semantic change* — the +most dangerous class — rather than a new field. + +A live flat panel cannot close this either: the one available has no BESS and no +Drives. It would attest the panel and circuit rows, which is where the two real +orphans are. + +Circuits are 96% of the entity surface and are attested. That is the useful half, +and it is clean. +""" + +from __future__ import annotations + +import dataclasses +import json +from pathlib import Path +from typing import Any + +import pytest + +from span_panel_api.models import V2HomieSchema +from span_panel_api_schema_0 import SchemaZeroAdapter +from span_panel_api_schema_1 import SchemaOneAdapter + +_FIXTURES = Path(__file__).parent / "fixtures" +_FLAT = _FIXTURES / "flat_wire.json" +_PC = Path(__file__).parent.parent / "packages" / "schema-1" / "spec" / "fixtures" / "simulator_wire.json" +_SERIAL = "sim-40t-001" + +EXPECTED_ORPHANS: dict[str, str] = { + "panel.dominant_power_source": ( + "split upstream into grid/grid-forming-entity and shed/asserted-islanding-state, " + "which are different controls on different devices; which successor is exposed, " + "if any, is an open product decision" + ), + "panel.grid_islandable": ( + "no v1.0 source; the flat panel advertised islandability as a panel property and " + "the redesign expresses it through the presence of a MID instead" + ), + "pv.relative_position": ( + "flat publishes pv/relative-position; schema_1 does not map the v1.0 equivalent yet. " + "Unlike the two above this is a gap rather than a decision, and closing it is cheap" + ), +} + +PROVISIONAL_DER: frozenset[str] = frozenset( + { + "battery.model", + "battery.product_name", + "battery.serial_number", + "pv.product_name", + } +) +"""Additions that may not be additions, because the flat reference never sends them. + +Each is classified `addition` only because the frozen flat simulator publishes no +BESS identity and no PV identity beyond `vendor-name`. This is narrower than "DER +is unverified": the simulator models both devices and the integration renders +their telemetry correctly against it, so `soc`, `soe`, `connected` and the rest +are attested. These four are the fields nothing sends and therefore nothing can +vouch for. + +Real flat firmware is documented to publish at least `bess/model`, so +`battery.model` is more likely a semantic change (SKU → designation) than a new +field. Resolving these needs a capture from flat firmware with a BESS attached, +which no available panel has. +""" + + +def _flat_schema(panel_size: int = 40) -> V2HomieSchema: + """No `data_model_version`: its absence is what marks a payload as flat.""" + return V2HomieSchema( + firmware_version="spanos2/r202627/01", + types_schema_hash="sha256:flat-capture", + types={ + "energy.ebus.device.circuit": { + "space": {"datatype": "integer", "format": f"1:{panel_size}:1"}, + }, + }, + ) + + +def _pc_schema() -> V2HomieSchema: + return V2HomieSchema( + firmware_version="spanos2/r202633/01", + types_schema_hash="sha256:pc-capture", + types={}, + data_model_version="1.0", + ) + + +def _feed(adapter: Any, capture_path: Path) -> Any: + """Replay a capture the way the retained store does: sorted, one at a time.""" + capture = json.loads(capture_path.read_text()) + for device in sorted(capture): + for key in sorted(capture[device]): + adapter.handle_message(f"ebus/5/{device}/{key}", capture[device][key]) + return adapter + + +@pytest.fixture(scope="module") +def flat() -> Any: + return _feed(SchemaZeroAdapter(serial_number=_SERIAL, schema=_flat_schema()), _FLAT).build_snapshot() + + +@pytest.fixture(scope="module") +def parent_child() -> Any: + return _feed(SchemaOneAdapter(serial_number=_SERIAL, schema=_pc_schema()), _PC).build_snapshot() + + +def _populated(obj: Any) -> set[str]: + if obj is None: + return set() + return {f.name for f in dataclasses.fields(obj) if getattr(obj, f.name) is not None} + + +def _classify(scope: str, flat_obj: Any, pc_obj: Any) -> tuple[set[str], set[str]]: + """Returns (additions, orphans) as dotted `scope.field` names.""" + before, after = _populated(flat_obj), _populated(pc_obj) + return ( + {f"{scope}.{name}" for name in after - before}, + {f"{scope}.{name}" for name in before - after}, + ) + + +def test_both_captures_describe_the_same_logical_panel(flat: Any, parent_child: Any) -> None: + """The premise. Without it every difference below is ambiguous between a + migration delta and two simulators being configured differently.""" + assert flat.serial_number == parent_child.serial_number == _SERIAL + assert len(flat.circuits) == len(parent_child.circuits) + assert set(flat.evse) == set(parent_child.evse) + + +def test_every_circuit_keeps_its_identity_across_the_migration(flat: Any, parent_child: Any) -> None: + """The single fact that decides whether history survives. + + `unique_id` is circuit-UUID-derived, so identical UUIDs on both sides mean the + registry keeps the same `entity_id`, `statistic_id` is unchanged, and + long-term statistics stay continuous. A UUID that moved would orphan a + circuit's entire history — 32 circuits' worth, silently. + """ + assert set(flat.circuits) == set(parent_child.circuits), ( + "circuit identities diverge across the migration; every non-matching circuit " "loses its recorder history" + ) + + +def test_no_circuit_field_is_orphaned(flat: Any, parent_child: Any) -> None: + """Circuits are 96% of the entity surface and the attested part of the flat + reference, so this is the strongest claim the harness can make.""" + orphans: set[str] = set() + for circuit_id in sorted(set(flat.circuits) & set(parent_child.circuits)): + _, found = _classify("circuit", flat.circuits[circuit_id], parent_child.circuits[circuit_id]) + orphans |= found + + assert not orphans, f"circuit fields that stop being published after the migration: {sorted(orphans)}" + + +def test_every_orphan_is_a_decision_someone_made(flat: Any, parent_child: Any) -> None: + """Phase 3's exit criterion: zero unclassified orphans. + + An unexpected entry here is a user-visible regression — an entity that exists + today, keeps its name, and stops updating. + """ + orphans: set[str] = set() + for scope, before, after in ( + ("panel", flat, parent_child), + ("battery", flat.battery, parent_child.battery), + ("pv", flat.pv, parent_child.pv), + ): + _, found = _classify(scope, before, after) + orphans |= found + + unexplained = sorted(orphans - set(EXPECTED_ORPHANS)) + assert ( + not unexplained + ), "these fields are populated on flat and absent on v1.0, and nobody decided that:\n " + "\n ".join(unexplained) + + stale = sorted(set(EXPECTED_ORPHANS) - orphans) + assert not stale, ( + "these are recorded as orphans but no longer are; delete them so the list keeps " f"meaning something: {stale}" + ) + + +def test_der_additions_that_the_flat_reference_cannot_vouch_for(flat: Any, parent_child: Any) -> None: + """Pins the provisional set, so it shrinks deliberately rather than drifting. + + These classify as additions only because the frozen flat simulator publishes + no BESS identity and almost no PV identity. If a flat capture ever arrives + from firmware with a BESS attached, this list should shrink and some members + will move to Severity 3 semantic changes instead. + """ + additions: set[str] = set() + for scope, before, after in ( + ("battery", flat.battery, parent_child.battery), + ("pv", flat.pv, parent_child.pv), + ): + found, _ = _classify(scope, before, after) + additions |= found + + assert additions == set(PROVISIONAL_DER), ( + f"the unattested DER addition set moved: {sorted(additions)}. Every member is a " + "field the flat simulator cannot vouch for; reconcile before treating it as new." + ) + + +def test_the_flat_reference_publishes_no_bess_identity() -> None: + """Why the set above is provisional, asserted rather than described. + + Reads the capture directly. If the flat simulator ever gains BESS identity, + this fails and the provisional set can be re-derived against something real. + """ + body = json.loads(_FLAT.read_text())[_SERIAL] + identity = sorted( + key + for key in body + if key.startswith("bess/") and key.split("/", 1)[1] in {"model", "product-name", "serial-number", "software-version"} + ) + + assert not identity, ( + f"the flat simulator now publishes BESS identity ({identity}); re-derive " + "PROVISIONAL_DER against it instead of assuming those fields are additions" + ) From 664930612303a4f6b7cba37ceb85e954df6550b7 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:00:09 -0700 Subject: [PATCH 047/115] docs(migration): a re-sourced field is only a risk where the mapper passes it through Correcting a claim in the previous commit. I wrote that battery.model is probably a semantic change -- 'the most dangerous class' -- because flat firmware publishes bess/model as the SKU while v1.0 has info/model carrying the designation. That reads the wire and stops. The user never sees which property a value came from; they see the value. And the mapper already absorbs this swap deliberately: info/part-number -> battery.model (the SKU stays in model) info/model -> battery.product_name (the designation gets a new field) So v1.0's battery.model is the SKU too. On a flat capture carrying BESS identity, battery.model and battery.serial_number would reclassify as IDENTITY, not as a semantic change. Only the two product_name fields look like genuine additions, the designation having had no flat home. The provisional set should shrink toward the benign bucket, and saying otherwise overstated the risk in a committed artifact. The general rule, which is the useful part: a re-sourced field is a migration risk only where the mapper passes the change through. Where it absorbs the change, the delta is real on the wire and invisible in the entity -- which is what the acceptance criterion asks for. It also narrows what Severity 3 is for: the swaps we choose not to absorb, each of which should be a decision rather than an oversight. 6 passed. --- tests/test_schema_migration_delta.py | 42 ++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/tests/test_schema_migration_delta.py b/tests/test_schema_migration_delta.py index 2d3620c..0cff1a1 100644 --- a/tests/test_schema_migration_delta.py +++ b/tests/test_schema_migration_delta.py @@ -51,14 +51,29 @@ | EVSE | full | `PROVISIONAL_DER` is exactly that unpublished set — not a hedge across DER -generally. And one member is probably misclassified already: `battery.model` -reads as an addition here, while the eBus consumer guide has flat firmware -publishing `bess/model` as the SKU, which would make it a *semantic change* — the -most dangerous class — rather than a new field. +generally. Some members are probably misclassified, but **in the benign +direction**, and the reason is worth understanding because it generalises. -A live flat panel cannot close this either: the one available has no BESS and no -Drives. It would attest the panel and circuit rows, which is where the two real -orphans are. +A user does not see which property a value came from; they see the value. So the +adapter is free to re-source a field as long as the *meaning* survives, and for +BESS identity it deliberately does: + + info/part-number -> battery.model (the SKU stays in `model`) + info/model -> battery.product_name (the designation gets a new field) + +Flat firmware publishes `bess/model` as the SKU. v1.0's `battery.model` is also +the SKU, by that mapping. So on a flat capture that carried BESS identity, +`battery.model` would reclassify as **identity** — not as a semantic change — and +`battery.serial_number` likewise. Only the two `product_name` fields look like +genuine additions, because the designation had no flat home at all. + +The general point: a re-sourced field is a migration risk only when the mapper +passes the change through. Where it absorbs the change, the delta is real in the +wire and invisible in the entity, which is the outcome §1 is asking for. + +A live flat panel cannot settle these either: the one available has no BESS and +no Drives. It would attest the panel and circuit rows, which is where the two +real orphans are. Circuits are 96% of the entity surface and are attested. That is the useful half, and it is clean. @@ -115,10 +130,15 @@ are attested. These four are the fields nothing sends and therefore nothing can vouch for. -Real flat firmware is documented to publish at least `bess/model`, so -`battery.model` is more likely a semantic change (SKU → designation) than a new -field. Resolving these needs a capture from flat firmware with a BESS attached, -which no available panel has. +Expect this set to shrink toward **identity**, not toward semantic change. The +mapper re-sources `battery.model` from `info/part-number`, which is the SKU that +flat's `bess/model` also carried, so a flat capture with BESS identity would move +`battery.model` and `battery.serial_number` into the identity bucket. The two +`product_name` entries are likely genuine additions: the designation had no flat +home. + +Resolving this needs a capture from flat firmware with a BESS attached, which no +available panel has. """ From 3d564a870fe0df99e948a73f84fda23dd3548324 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:03:13 -0700 Subject: [PATCH 048/115] docs(migration): absorb protects stability, not value -- make it a two-axis call The previous commit landed a one-sided rule. 'A re-sourced field is a risk only where the mapper passes the change through' is true, but read as guidance it says absorb everything, and that would quietly cost users the extra data v1.0 carries. Two questions, not one. Does anything hinge on it? State and telemetry drive templates, automations and recorder statistics, and a silent value change there is the dangerous class -- grid_state is the worked example, and we shipped it wrong once. Metadata renders on a device card. Nothing in the delta document is an example of metadata breaking anything. Is it a change, or is it more? Adding a field is a different act from changing one. A new field cannot break an automation that never referenced it, so additions are near-free whatever their kind; the only cost is entity-list noise, which is taste rather than migration risk. That makes battery.product_name a free win rather than a hazard, and it reopens the BESS identity resolution as a product call. Keeping the SKU in battery.model is maximally conservative; putting the human designation there is defensible and probably better, since the device card is the main consumer and nothing automates on it. Recorded as a decision to revisit rather than left as an artifact of caution. Same for the MID's own serial-number and vendor-name, published and unconsumed today. No behaviour change: EXPECTED_ORPHANS and PROVISIONAL_DER are about entities that would stop arriving, and nothing here argues against surfacing new ones. 6 passed. --- tests/test_schema_migration_delta.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_schema_migration_delta.py b/tests/test_schema_migration_delta.py index 0cff1a1..b875adf 100644 --- a/tests/test_schema_migration_delta.py +++ b/tests/test_schema_migration_delta.py @@ -71,6 +71,20 @@ passes the change through. Where it absorbs the change, the delta is real in the wire and invisible in the entity, which is the outcome §1 is asking for. +Absorbing everything would be the wrong reading, though, and this harness should +not be mistaken for an argument to. Absorption protects stability, not value, and +v1.0 carries more data than flat did. The risk of changing a field scales with how +likely something compares it — state and telemetry drive automations and +statistics, metadata renders on a device card and essentially nothing hinges on +it. And adding a field is not the same act as changing one: a new field cannot +break an automation that never referenced it. + +So `battery.product_name` is a free win rather than a hazard, and the delta +document treats "keep the SKU in `battery.model`" as a product call worth +revisiting rather than a default. `EXPECTED_ORPHANS` and `PROVISIONAL_DER` are +about entities that would *stop* arriving; nothing here argues against surfacing +new ones. + A live flat panel cannot settle these either: the one available has no BESS and no Drives. It would attest the panel and circuit rows, which is where the two real orphans are. From eba78bd5b76672da8319fa34f93d4fb8743b6245 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:18:21 -0700 Subject: [PATCH 049/115] test(migration): live-panel differential, and the degradation both diffs missed Phase 3b harness. scripts/capture_live_flat.py takes a retained capture from a real flat panel; tests/test_live_flat_differential.py runs schema_0 over it and over the frozen simulator's capture and diffs which fields each populates. Where they agree the simulator is attested rather than assumed; where they differ the panel is ground truth. The capture is gitignored -- it carries the panel serial, which is also its MQTT username, plus the household's circuit names and real consumption. The tests assert and print field NAMES only, never values, so a failure message cannot leak a reading. Absent the capture all five skip, which is the normal state and what CI does. Falsifying the harness against a stand-in found a real hole, and it was in the Phase 3a classification too. Deleting core/door from a capture changed nothing: door_state falls back to 'UNKNOWN' rather than None, so a population diff sees a field that is still 'populated' while carrying no answer. Any sentinel-defaulting field could stop being published and classify as IDENTITY -- the safest bucket -- while a user sees a permanently useless entity. Worse than an orphan, because an orphan goes stale visibly and UNKNOWN reads as a working sensor that does not know. Closed on both sides. The classification gains a fourth bucket, degraded: a real value on flat, a sentinel on v1.0. Kept distinct from orphan rather than folded in, because UNKNOWN is a legal state for several of these enums -- what makes it a delta is the transition. Measured, the set is exactly two, both already Severity 2: panel.dsm_state reconstructable from the MID's islanding-state panel.current_run_config no v1.0 source identified So the mechanical test now agrees with the document instead of quietly calling them identities. The differential gains the same check across the two captures. Falsified in both directions, each restored and cmp-verified: deleting door/state from the v1.0 capture grows the degraded set and fails; the original core/door mutation that slipped through now fails naming door_state. 578 passed, 5 skipped. --- .env.example | 20 +++ .gitignore | 6 + scripts/capture_live_flat.py | 147 +++++++++++++++++++++ tests/test_live_flat_differential.py | 189 +++++++++++++++++++++++++++ tests/test_schema_migration_delta.py | 67 ++++++++++ 5 files changed, 429 insertions(+) create mode 100644 scripts/capture_live_flat.py create mode 100644 tests/test_live_flat_differential.py diff --git a/.env.example b/.env.example index f9ee308..900782b 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,23 @@ # capture is compared on shape, because its values are perturbed by the # simulator's `noise_factor` and an advancing clock. #PANELBENCH_DIR=/path/to/panelbench + +# --------------------------------------------------------------------------- +# A live SPAN panel running flat firmware (optional, and nothing needs it) +# --------------------------------------------------------------------------- +# +# Enables `scripts/capture_live_flat.py`, which takes a retained capture from a +# real panel so the frozen flat simulator can be measured against firmware rather +# than trusted. Without it, `test_live_flat_differential.py` skips. +# +# The username IS the panel serial, so treat both of these as secrets and keep +# them here. The capture the script writes is gitignored for the same reason: it +# carries the serial, the household's circuit names and real consumption. Only the +# differential's verdict is ever committed. +# +# TLS is on and certificate validation is off: the panel presents a self-signed +# certificate. +#LIVE_PANEL_HOST=192.168.1.50 +#LIVE_PANEL_PORT=8883 +#LIVE_PANEL_USERNAME=your-panel-serial +#LIVE_PANEL_PASSWORD= diff --git a/.gitignore b/.gitignore index f838ffa..cf89f56 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,9 @@ dmypy.json coverage_output.log **/.DS_Store .local_coverage_data + +# Captures taken from a real panel. These carry the panel's serial (which is also +# its MQTT username), the household's circuit names, and real consumption — none +# of which belongs in a repository. The differential that reads them commits its +# *verdict* only, never the capture, and skips when the file is absent. +tests/fixtures/live_*.json diff --git a/scripts/capture_live_flat.py b/scripts/capture_live_flat.py new file mode 100644 index 0000000..3374c43 --- /dev/null +++ b/scripts/capture_live_flat.py @@ -0,0 +1,147 @@ +"""Capture the retained tree from a live SPAN panel running flat firmware. + +Produces `tests/fixtures/live_flat_wire.json`, which is **gitignored**. That file +carries the panel's serial (which is also its MQTT username), the household's +circuit names and real consumption, so it stays on the machine that took it. What +gets committed is the verdict of `tests/test_live_flat_differential.py`, never the +capture. + +Reads credentials from `.env` (see `.env.example`): + + LIVE_PANEL_HOST LIVE_PANEL_PORT LIVE_PANEL_USERNAME LIVE_PANEL_PASSWORD + +Run: + + uv run python scripts/capture_live_flat.py + +Why it exists: the flat side of the migration classification is the frozen +simulator, a proxy for firmware. This measures the proxy. Where the panel and the +simulator agree, the simulator is attested; where they differ, the panel is +ground truth and the simulator is wrong. + +TLS with verification off, matching how the panel is reached in practice — it +presents a self-signed certificate. +""" + +import json +import os +import pathlib +import ssl +import sys +import threading +import time + +import paho.mqtt.client as mqtt + +_REPO = pathlib.Path(__file__).resolve().parent.parent +OUT = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else _REPO / "tests" / "fixtures" / "live_flat_wire.json" + +# Stop when nothing new has arrived for this long. A retained store replays in a +# burst on subscribe, so silence is the signal that the burst is over. +QUIET_SECONDS = 5.0 +MAX_SECONDS = 60.0 + + +def _load_dotenv() -> None: + path = _REPO / ".env" + if not path.exists(): + return + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +def main() -> int: + _load_dotenv() + + host = os.environ.get("LIVE_PANEL_HOST", "") + port = int(os.environ.get("LIVE_PANEL_PORT") or 8883) + username = os.environ.get("LIVE_PANEL_USERNAME", "") + password = os.environ.get("LIVE_PANEL_PASSWORD", "") + + missing = [ + name + for name, value in ( + ("LIVE_PANEL_HOST", host), + ("LIVE_PANEL_USERNAME", username), + ("LIVE_PANEL_PASSWORD", password), + ) + if not value + ] + if missing: + print(f"missing in .env: {', '.join(missing)} — see .env.example") + return 2 + + retained: dict[str, str] = {} + last_message = [time.monotonic()] + connected = threading.Event() + failed: list[str] = [] + + def on_connect(client: mqtt.Client, _u: object, _f: object, reason: object, _p: object = None) -> None: + code = getattr(reason, "value", reason) + if code != 0: + failed.append(f"connect refused: {reason}") + connected.set() + return + # The panel publishes its whole tree under its own serial. + client.subscribe(f"ebus/5/{username}/#", qos=1) + connected.set() + + def on_message(_c: object, _u: object, message: mqtt.MQTTMessage) -> None: + if message.retain: + retained[message.topic] = message.payload.decode(errors="replace") + last_message[0] = time.monotonic() + + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="span-flat-capture") + client.username_pw_set(username, password) + client.tls_set(cert_reqs=ssl.CERT_NONE) + client.tls_insecure_set(True) + client.on_connect = on_connect + client.on_message = on_message + + print(f"connecting to {host}:{port} …") + client.connect(host, port, keepalive=30) + client.loop_start() + + if not connected.wait(timeout=20): + client.loop_stop() + print("timed out waiting for CONNACK") + return 1 + if failed: + client.loop_stop() + print(failed[0]) + return 1 + + started = time.monotonic() + while time.monotonic() - started < MAX_SECONDS: + if retained and time.monotonic() - last_message[0] > QUIET_SECONDS: + break + time.sleep(0.25) + + client.loop_stop() + client.disconnect() + + if not retained: + print("connected but received no retained messages; is the topic prefix right?") + return 1 + + devices: dict[str, dict[str, str]] = {} + for topic, payload in sorted(retained.items()): + parts = topic.split("/") + if len(parts) < 4: + continue + devices.setdefault(parts[2], {})["/".join(parts[3:])] = payload + + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(json.dumps(devices, indent=2, sort_keys=True) + "\n") + + topics = sum(len(v) for v in devices.values()) + print(f"devices: {len(devices)} topics: {topics}") + print(f"written to {OUT} (gitignored)") + return 0 + + +raise SystemExit(main()) diff --git a/tests/test_live_flat_differential.py b/tests/test_live_flat_differential.py new file mode 100644 index 0000000..8ceb0f1 --- /dev/null +++ b/tests/test_live_flat_differential.py @@ -0,0 +1,189 @@ +"""Measure the frozen flat simulator against a real panel running flat firmware. + +Phase 3b. The migration classification in `test_schema_migration_delta.py` uses +the frozen simulator as its flat reference, which is a *proxy* for firmware. This +measures the proxy: run `schema_0` over both a live capture and the simulator +capture and diff which `SpanPanelSnapshot` fields each populates. + +- agree → the simulator is attested for that field, not merely assumed +- differ → the panel is ground truth and the simulator is wrong + +**Skips without a capture, and that is the normal state.** The capture is +gitignored — it carries the panel's serial (which is also its MQTT username), the +household's circuit names and real consumption. Take one with +`scripts/capture_live_flat.py`; what lands in the repository is this file's +verdict, never the data. + +**Values are never asserted and never printed.** Only field *names* and counts +appear in output, so a failure message cannot leak a circuit name or a reading. +Population is also the only comparable thing: two panels in different houses at +different moments share no values. + +What this can and cannot settle: + +- **Can:** the panel and circuit rows, which are 96% of the entity surface and + where both of the migration's real orphans live. +- **Cannot:** the four `PROVISIONAL_DER` rows. The available panel has no BESS and + no Drives, so it is silent on exactly the fields the simulator is silent on. + A differential between two silences proves nothing, and `test_the_live_panel_ + cannot_attest_der_identity` records that rather than letting the agreement + count as evidence. +""" + +from __future__ import annotations + +import dataclasses +import json +from pathlib import Path +from typing import Any + +import pytest + +from span_panel_api.models import V2HomieSchema +from span_panel_api_schema_0 import SchemaZeroAdapter + +_FIXTURES = Path(__file__).parent / "fixtures" +_LIVE = _FIXTURES / "live_flat_wire.json" +_SIM = _FIXTURES / "flat_wire.json" + +pytestmark = pytest.mark.skipif( + not _LIVE.exists(), + reason="no live panel capture; run scripts/capture_live_flat.py (see .env.example)", +) + +DER_SCOPES = ("battery", "pv") +"""Scopes the available panel cannot speak to: it has no BESS and no Drives.""" + +_SENTINEL = "UNKNOWN" +"""A value that occupies a field without informing it — see the degradation test.""" + + +def _schema(panel_size: int) -> V2HomieSchema: + """No `data_model_version`: its absence is what marks a payload as flat.""" + return V2HomieSchema( + firmware_version="flat", + types_schema_hash="sha256:differential", + types={ + "energy.ebus.device.circuit": { + "space": {"datatype": "integer", "format": f"1:{panel_size}:1"}, + }, + }, + ) + + +def _snapshot(path: Path) -> Any: + capture = json.loads(path.read_text()) + serial = next(iter(capture)) + adapter = SchemaZeroAdapter(serial_number=serial, schema=_schema(40)) + for device in sorted(capture): + for key in sorted(capture[device]): + adapter.handle_message(f"ebus/5/{device}/{key}", capture[device][key]) + return adapter.build_snapshot() + + +def _populated(obj: Any) -> set[str]: + if obj is None: + return set() + return {f.name for f in dataclasses.fields(obj) if getattr(obj, f.name) is not None} + + +@pytest.fixture(scope="module") +def live() -> Any: + return _snapshot(_LIVE) + + +@pytest.fixture(scope="module") +def sim() -> Any: + return _snapshot(_SIM) + + +def test_the_simulator_populates_every_panel_field_the_panel_does(live: Any, sim: Any) -> None: + """The claim the migration classification rests on. + + A panel field the real panel publishes and the simulator does not is a field + the classification never saw — so it could be silently orphaned by the + migration with nothing to notice. + """ + missing = sorted(_populated(live) - _populated(sim) - {"circuits", "battery", "pv", "evse"}) + + assert not missing, ( + "the real panel populates these and the frozen simulator does not, so the " + f"migration classification has no evidence about them: {missing}" + ) + + +def test_the_simulator_invents_no_panel_field_the_panel_lacks(live: Any, sim: Any) -> None: + """The other direction, which is the subtler error. + + A field only the simulator populates makes the classification treat something + as surviving the migration when no real panel ever had it. Held separately + from the test above because the remedy differs: one is a simulator gap, this + is a simulator fiction. + """ + invented = sorted(_populated(sim) - _populated(live) - {"circuits", "battery", "pv", "evse"}) + + assert not invented, ( + "the frozen simulator populates these and the real panel does not; the " + f"classification may be treating a simulator artifact as a real entity: {invented}" + ) + + +def test_circuit_fields_agree_between_the_panel_and_the_simulator(live: Any, sim: Any) -> None: + """Circuits are 96% of the entity surface, so this is the bulk of the attestation. + + Compares the *set of populated field names* per circuit, not values and not + circuit identities — the panel's circuits are a different household's and + their ids are not ours to compare against a fixture. + """ + if not live.circuits or not sim.circuits: + pytest.skip("one side published no circuits") + + live_shape = {frozenset(_populated(c)) for c in live.circuits.values()} + sim_shape = {frozenset(_populated(c)) for c in sim.circuits.values()} + + only_live = sorted({name for shape in live_shape for name in shape} - {name for shape in sim_shape for name in shape}) + only_sim = sorted({name for shape in sim_shape for name in shape} - {name for shape in live_shape for name in shape}) + + assert not only_live and not only_sim, ( + f"circuit fields the panel populates and the simulator does not: {only_live}; " + f"fields the simulator populates and the panel does not: {only_sim}" + ) + + +def test_no_panel_field_answers_on_one_side_and_reads_unknown_on_the_other(live: Any, sim: Any) -> None: + """Population alone cannot see a field degrade, which is how this was found. + + Deleting `core/door` from a capture changes nothing in the two tests above, + because `door_state` falls back to `UNKNOWN` rather than `None` — the field + stays "populated" while carrying no answer. So the simulator could differ from + a real panel on any sentinel-defaulting field and the diff would report + agreement. + """ + ignore = {"circuits", "battery", "pv", "evse"} + disagreeing = sorted( + f.name + for f in dataclasses.fields(live) + if f.name not in ignore and (getattr(live, f.name) == _SENTINEL) != (getattr(sim, f.name, None) == _SENTINEL) + ) + + assert not disagreeing, ( + f"these read {_SENTINEL!r} on one side and carry an answer on the other, so the " + f"simulator is not faithful for them: {disagreeing}" + ) + + +def test_the_live_panel_cannot_attest_der_identity(live: Any) -> None: + """Records the limit, so agreement elsewhere is not over-read. + + The available panel has no BESS and no Drives. It is therefore silent on + exactly the fields the simulator is silent on, and a differential between two + silences is not evidence. If a panel with DER hardware is ever captured, this + fails and `PROVISIONAL_DER` in the migration classification can finally be + re-derived against something real. + """ + attested = sorted(scope for scope in DER_SCOPES if _populated(getattr(live, scope, None))) + + assert not attested, ( + f"this panel now reports {attested}; re-derive PROVISIONAL_DER in " + "test_schema_migration_delta.py against it instead of assuming those fields are additions" + ) diff --git a/tests/test_schema_migration_delta.py b/tests/test_schema_migration_delta.py index b875adf..1ab1a52 100644 --- a/tests/test_schema_migration_delta.py +++ b/tests/test_schema_migration_delta.py @@ -127,6 +127,23 @@ ), } +EXPECTED_DEGRADED: dict[str, str] = { + "panel.dsm_state": ( + "reads UNKNOWN on v1.0 where flat answered. Its authoritative input survives as " + "the MID's grid/islanding-state and its fallback as grid power, so the UNKNOWN is " + "more conservative than the data requires — reconstruction is an open item" + ), + "panel.current_run_config": ( + "reads UNKNOWN on v1.0 where flat answered; no v1.0 source identified yet. " "Severity 2 in the delta document" + ), +} +"""Fields that survive the migration as entities but stop carrying an answer. + +Worse for a user than an orphan, because an orphan goes stale and is noticeable +while `UNKNOWN` reads as a working sensor that does not know. Both members are +already documented; the test exists so a third cannot appear quietly. +""" + PROVISIONAL_DER: frozenset[str] = frozenset( { "battery.model", @@ -197,12 +214,39 @@ def parent_child() -> Any: return _feed(SchemaOneAdapter(serial_number=_SERIAL, schema=_pc_schema()), _PC).build_snapshot() +_SENTINEL = "UNKNOWN" +"""A value that occupies a field without informing it. + +Found by falsifying the differential in `test_live_flat_differential.py`: deleting +`core/door` from a capture changed nothing, because `door_state` falls back to +`UNKNOWN` rather than to `None`. A population diff cannot see a field degrade that +way, so a field that stopped being published would classify as *identity* — the +safest bucket — while a user sees a permanently useless entity. +""" + + def _populated(obj: Any) -> set[str]: if obj is None: return set() return {f.name for f in dataclasses.fields(obj) if getattr(obj, f.name) is not None} +def _degraded(before: Any, after: Any) -> set[str]: + """Fields carrying a real value on flat and only a sentinel on v1.0. + + A fourth bucket rather than folded into orphans, because `UNKNOWN` is a legal + state for several of these enums. What makes it a delta is the *transition*: + the flat panel answered and the v1.0 panel does not. + """ + if before is None or after is None: + return set() + return { + f.name + for f in dataclasses.fields(after) + if getattr(after, f.name) == _SENTINEL and getattr(before, f.name, None) not in (None, _SENTINEL) + } + + def _classify(scope: str, flat_obj: Any, pc_obj: Any) -> tuple[set[str], set[str]]: """Returns (additions, orphans) as dotted `scope.field` names.""" before, after = _populated(flat_obj), _populated(pc_obj) @@ -270,6 +314,29 @@ def test_every_orphan_is_a_decision_someone_made(flat: Any, parent_child: Any) - ) +def test_every_degraded_field_is_a_known_one(flat: Any, parent_child: Any) -> None: + """Fields that survive as entities but stop carrying an answer. + + Invisible to the population diff above — the entity exists and holds a string, + so nothing looks wrong — which is why this is separate. To a user it is worse + than an orphan: an orphan goes stale and is noticeable, while `UNKNOWN` looks + like a working sensor reporting that it does not know. + """ + degraded: set[str] = set() + for scope, before, after in ( + ("panel", flat, parent_child), + ("battery", flat.battery, parent_child.battery), + ("pv", flat.pv, parent_child.pv), + ): + degraded |= {f"{scope}.{name}" for name in _degraded(before, after)} + + assert degraded == set(EXPECTED_DEGRADED), ( + f"the set of fields that answer on flat and read {_SENTINEL!r} on v1.0 moved: " + f"{sorted(degraded)}. Both known members have a reconstruction recorded in the " + "delta document; a new one is a regression." + ) + + def test_der_additions_that_the_flat_reference_cannot_vouch_for(flat: Any, parent_child: Any) -> None: """Pins the provisional set, so it shrinks deliberately rather than drifting. From 4d2b7837c3618cccd279a15d3191304932bbb89d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:03:54 -0700 Subject: [PATCH 050/115] test(migration): measure the flat simulator against real firmware, and fix a misclassification Phase 3b, run against a live panel. The verdict is better than expected and it corrected Phase 3a, which is what it was built to do. At the wire level, per device class, the frozen simulator publishes exactly the properties real firmware publishes: core identical lugs-upstream identical lugs-downstream identical circuits identical pv firmware sends product-name, the simulator does not One gap, and it mattered. Phase 3a classified pv.product_name as a v1.0 ADDITION purely because the simulator never sent it. Firmware does, so it is an IDENTITY -- an entity users already have, which survives the migration rather than appearing at it. Moved to ATTESTED_AGAINST_FIRMWARE, and PROVISIONAL_DER shrank to the three battery rows, which this panel cannot settle because it has no BESS. The first draft of this differential asked the wrong question and I rewrote it. Diffing SpanPanelSnapshot fields produced three findings that were all artefacts: grid_state differs because flat sources it from bess/grid-state and the panel has no BESS; door_state and vendor_cloud differ because both sides publish them and two panels in different houses are in different states. Comparing published property sets per device class instead is the actual fidelity question, and it is stable across houses, across time, and across which DER hardware is installed. That also retires the sentinel comparison here. It stays in the Phase 3a classification, where a transition from a real value to UNKNOWN between two schemas of the same panel is meaningful; between two different panels it is not. The capture stays gitignored -- serial, circuit names, real consumption. Only property names are asserted or printed. 585 passed with the capture present, and the suite skips cleanly without it. Falsified: adding a core property to the firmware capture fails naming it and telling the reader to check whether 3a misclassified anything. --- tests/test_live_flat_differential.py | 219 ++++++++++++--------------- tests/test_schema_migration_delta.py | 40 +++-- 2 files changed, 128 insertions(+), 131 deletions(-) diff --git a/tests/test_live_flat_differential.py b/tests/test_live_flat_differential.py index 8ceb0f1..f409bd2 100644 --- a/tests/test_live_flat_differential.py +++ b/tests/test_live_flat_differential.py @@ -2,46 +2,41 @@ Phase 3b. The migration classification in `test_schema_migration_delta.py` uses the frozen simulator as its flat reference, which is a *proxy* for firmware. This -measures the proxy: run `schema_0` over both a live capture and the simulator -capture and diff which `SpanPanelSnapshot` fields each populates. +measures the proxy, so the classification rests on something checked rather than +something assumed. -- agree → the simulator is attested for that field, not merely assumed -- differ → the panel is ground truth and the simulator is wrong +**Compares published property sets, not snapshot values.** The first draft diffed +`SpanPanelSnapshot` fields and produced three findings that were all artefacts of +the question rather than the answer: `grid_state` differed because flat sources it +from `bess/grid-state` and the panel has no BESS; `door_state` and `vendor_cloud` +differed because both sides publish them and two panels in different houses are +simply in different states. None of that is infidelity. + +Fidelity is *does the simulator publish the same properties firmware does*, per +device class, for the classes both have. That question is stable across houses, +across time, and across which DER hardware is installed. **Skips without a capture, and that is the normal state.** The capture is gitignored — it carries the panel's serial (which is also its MQTT username), the household's circuit names and real consumption. Take one with `scripts/capture_live_flat.py`; what lands in the repository is this file's -verdict, never the data. - -**Values are never asserted and never printed.** Only field *names* and counts -appear in output, so a failure message cannot leak a circuit name or a reading. -Population is also the only comparable thing: two panels in different houses at -different moments share no values. - -What this can and cannot settle: - -- **Can:** the panel and circuit rows, which are 96% of the entity surface and - where both of the migration's real orphans live. -- **Cannot:** the four `PROVISIONAL_DER` rows. The available panel has no BESS and - no Drives, so it is silent on exactly the fields the simulator is silent on. - A differential between two silences proves nothing, and `test_the_live_panel_ - cannot_attest_der_identity` records that rather than letting the agreement - count as evidence. +verdict, never the data. Property *names* are asserted and printed; values never +are, so a failure cannot leak a circuit name or a reading. + +The verdict as of 2026-08-08: the simulator is faithful for `core`, both `lugs` +and circuits — identical property sets — with exactly one gap, `pv/product-name`. +That gap corrected a real misclassification in Phase 3a, which is what this was +built to do. """ from __future__ import annotations -import dataclasses import json +import re from pathlib import Path -from typing import Any import pytest -from span_panel_api.models import V2HomieSchema -from span_panel_api_schema_0 import SchemaZeroAdapter - _FIXTURES = Path(__file__).parent / "fixtures" _LIVE = _FIXTURES / "live_flat_wire.json" _SIM = _FIXTURES / "flat_wire.json" @@ -51,139 +46,121 @@ reason="no live panel capture; run scripts/capture_live_flat.py (see .env.example)", ) -DER_SCOPES = ("battery", "pv") -"""Scopes the available panel cannot speak to: it has no BESS and no Drives.""" +_UUID = re.compile(r"^[0-9a-f]{32}$") -_SENTINEL = "UNKNOWN" -"""A value that occupies a field without informing it — see the degradation test.""" +SHARED_PREFIXES = ("core", "pv", "lugs-upstream", "lugs-downstream") +"""Single-instance device classes present on both the panel and the simulator.""" +KNOWN_GAPS: dict[str, tuple[str, ...]] = { + "pv": ("product-name",), +} +"""Properties real firmware publishes that the frozen simulator does not. -def _schema(panel_size: int) -> V2HomieSchema: - """No `data_model_version`: its absence is what marks a payload as flat.""" - return V2HomieSchema( - firmware_version="flat", - types_schema_hash="sha256:differential", - types={ - "energy.ebus.device.circuit": { - "space": {"datatype": "integer", "format": f"1:{panel_size}:1"}, - }, - }, - ) - +One entry, and it earned its keep immediately: Phase 3a classified +`pv.product_name` as a v1.0 *addition* purely because the simulator never sent it. +Real firmware does, so it is an identity — the entity exists today and survives. +`PROVISIONAL_DER` shrank accordingly. -def _snapshot(path: Path) -> Any: - capture = json.loads(path.read_text()) - serial = next(iter(capture)) - adapter = SchemaZeroAdapter(serial_number=serial, schema=_schema(40)) - for device in sorted(capture): - for key in sorted(capture[device]): - adapter.handle_message(f"ebus/5/{device}/{key}", capture[device][key]) - return adapter.build_snapshot() +The flat simulator is frozen, so this is a permanent gap to compensate for rather +than a bug to file. +""" -def _populated(obj: Any) -> set[str]: - if obj is None: - return set() - return {f.name for f in dataclasses.fields(obj) if getattr(obj, f.name) is not None} +def _body(path: Path) -> dict[str, str]: + capture = json.loads(path.read_text()) + return capture[next(iter(capture))] @pytest.fixture(scope="module") -def live() -> Any: - return _snapshot(_LIVE) +def live() -> dict[str, str]: + return _body(_LIVE) @pytest.fixture(scope="module") -def sim() -> Any: - return _snapshot(_SIM) +def sim() -> dict[str, str]: + return _body(_SIM) -def test_the_simulator_populates_every_panel_field_the_panel_does(live: Any, sim: Any) -> None: - """The claim the migration classification rests on. +def _properties(body: dict[str, str], prefix: str) -> set[str]: + return {key.split("/", 1)[1] for key in body if key.startswith(f"{prefix}/")} - A panel field the real panel publishes and the simulator does not is a field - the classification never saw — so it could be silently orphaned by the - migration with nothing to notice. - """ - missing = sorted(_populated(live) - _populated(sim) - {"circuits", "battery", "pv", "evse"}) - assert not missing, ( - "the real panel populates these and the frozen simulator does not, so the " - f"migration classification has no evidence about them: {missing}" - ) +def _circuit_properties(body: dict[str, str]) -> set[str]: + ids = {key.split("/")[0] for key in body if _UUID.match(key.split("/")[0])} + return {key.split("/", 1)[1] for key in body if key.split("/")[0] in ids} -def test_the_simulator_invents_no_panel_field_the_panel_lacks(live: Any, sim: Any) -> None: - """The other direction, which is the subtler error. +@pytest.mark.parametrize("prefix", SHARED_PREFIXES) +def test_the_simulator_publishes_what_firmware_publishes(prefix: str, live: dict[str, str], sim: dict[str, str]) -> None: + """Per device class, both directions, with the one known gap allowed. - A field only the simulator populates makes the classification treat something - as surviving the migration when no real panel ever had it. Held separately - from the test above because the remedy differs: one is a simulator gap, this - is a simulator fiction. + A property firmware sends and the simulator does not means the migration + classification never saw it — `pv/product-name` is exactly that, and it was + misclassified as an addition until this measured it. A property the simulator + sends and firmware does not would be worse: the classification would be + reasoning about an entity nobody has. """ - invented = sorted(_populated(sim) - _populated(live) - {"circuits", "battery", "pv", "evse"}) + panel, simulated = _properties(live, prefix), _properties(sim, prefix) + if not panel and not simulated: + pytest.skip(f"neither side publishes {prefix}") + + missing = sorted(panel - simulated - set(KNOWN_GAPS.get(prefix, ()))) + invented = sorted(simulated - panel) + assert not missing, ( + f"firmware publishes {prefix} properties the frozen simulator does not: {missing}. " + "The migration classification has no evidence about them; add them to KNOWN_GAPS " + "and check whether Phase 3a misclassified anything as an addition." + ) assert not invented, ( - "the frozen simulator populates these and the real panel does not; the " - f"classification may be treating a simulator artifact as a real entity: {invented}" + f"the simulator publishes {prefix} properties firmware does not: {invented}. " + "The classification may be reasoning about an entity no real panel has." ) -def test_circuit_fields_agree_between_the_panel_and_the_simulator(live: Any, sim: Any) -> None: +def test_circuit_properties_are_identical(live: dict[str, str], sim: dict[str, str]) -> None: """Circuits are 96% of the entity surface, so this is the bulk of the attestation. - Compares the *set of populated field names* per circuit, not values and not - circuit identities — the panel's circuits are a different household's and - their ids are not ours to compare against a fixture. + Property names only. Circuit *ids* are not compared — the panel's are a + different household's — and neither are values. """ - if not live.circuits or not sim.circuits: - pytest.skip("one side published no circuits") - - live_shape = {frozenset(_populated(c)) for c in live.circuits.values()} - sim_shape = {frozenset(_populated(c)) for c in sim.circuits.values()} + panel, simulated = _circuit_properties(live), _circuit_properties(sim) + assert panel and simulated, "one side published no circuits" - only_live = sorted({name for shape in live_shape for name in shape} - {name for shape in sim_shape for name in shape}) - only_sim = sorted({name for shape in sim_shape for name in shape} - {name for shape in live_shape for name in shape}) - - assert not only_live and not only_sim, ( - f"circuit fields the panel populates and the simulator does not: {only_live}; " - f"fields the simulator populates and the panel does not: {only_sim}" + assert panel == simulated, ( + f"circuit properties firmware publishes and the simulator does not: {sorted(panel - simulated)}; " + f"the reverse: {sorted(simulated - panel)}" ) -def test_no_panel_field_answers_on_one_side_and_reads_unknown_on_the_other(live: Any, sim: Any) -> None: - """Population alone cannot see a field degrade, which is how this was found. +def test_the_known_gap_is_still_exactly_one(live: dict[str, str], sim: dict[str, str]) -> None: + """`KNOWN_GAPS` relaxes the check above, so it has to stay earned. - Deleting `core/door` from a capture changes nothing in the two tests above, - because `door_state` falls back to `UNKNOWN` rather than `None` — the field - stays "populated" while carrying no answer. So the simulator could differ from - a real panel on any sentinel-defaulting field and the diff would report - agreement. + Fails in both directions: a gap that closed should be deleted so the list keeps + meaning something, and a gap that never existed should never have been added. """ - ignore = {"circuits", "battery", "pv", "evse"} - disagreeing = sorted( - f.name - for f in dataclasses.fields(live) - if f.name not in ignore and (getattr(live, f.name) == _SENTINEL) != (getattr(sim, f.name, None) == _SENTINEL) - ) + stale = { + prefix: sorted(name for name in names if name in _properties(sim, prefix)) for prefix, names in KNOWN_GAPS.items() + } + still_gaps = {prefix: names for prefix, names in stale.items() if names} - assert not disagreeing, ( - f"these read {_SENTINEL!r} on one side and carry an answer on the other, so the " - f"simulator is not faithful for them: {disagreeing}" - ) + assert not still_gaps, f"the simulator now publishes these, so they are no longer gaps: {still_gaps}" -def test_the_live_panel_cannot_attest_der_identity(live: Any) -> None: - """Records the limit, so agreement elsewhere is not over-read. +def test_which_der_hardware_this_panel_can_attest(live: dict[str, str]) -> None: + """Records what the available panel does and does not settle. - The available panel has no BESS and no Drives. It is therefore silent on - exactly the fields the simulator is silent on, and a differential between two - silences is not evidence. If a panel with DER hardware is ever captured, this - fails and `PROVISIONAL_DER` in the migration classification can finally be - re-derived against something real. + It has PV and no BESS, so it attests the `pv` rows of `PROVISIONAL_DER` and is + silent on the `battery` ones — and a differential between two silences is not + evidence. Pinned so that capturing a panel with a BESS fails here and prompts + re-deriving that set against something real. """ - attested = sorted(scope for scope in DER_SCOPES if _populated(getattr(live, scope, None))) - - assert not attested, ( - f"this panel now reports {attested}; re-derive PROVISIONAL_DER in " - "test_schema_migration_delta.py against it instead of assuming those fields are additions" + has_bess = any(key.startswith("bess/") for key in live) + has_pv = any(key.startswith("pv/") for key in live) + + assert has_pv, "this panel no longer reports PV; the pv attestation in KNOWN_GAPS rests on it" + assert not has_bess, ( + "this panel now reports a BESS. Re-derive PROVISIONAL_DER in " + "test_schema_migration_delta.py against it — the battery rows have never been " + "measured against real firmware." ) diff --git a/tests/test_schema_migration_delta.py b/tests/test_schema_migration_delta.py index 1ab1a52..04e0044 100644 --- a/tests/test_schema_migration_delta.py +++ b/tests/test_schema_migration_delta.py @@ -144,12 +144,27 @@ already documented; the test exists so a third cannot appear quietly. """ +ATTESTED_AGAINST_FIRMWARE: dict[str, str] = { + "pv.product_name": ( + "classified an addition here only because the frozen simulator never sends " + "pv/product-name. A capture from real flat firmware does send it, so this is an " + "IDENTITY — the entity exists today and survives the migration. Measured by " + "test_live_flat_differential.py; the simulator gap is recorded there as KNOWN_GAPS" + ), +} +"""Rows the mechanical diff gets wrong, corrected by a capture from real firmware. + +The classification can only see what its flat reference sends, so a simulator gap +reads as a v1.0 addition. This is where Phase 3b pays for itself: one row moved +from *addition* to *identity* on evidence, and it moved in the direction that +matters — a field we thought was new turns out to be one users already have. +""" + PROVISIONAL_DER: frozenset[str] = frozenset( { "battery.model", "battery.product_name", "battery.serial_number", - "pv.product_name", } ) """Additions that may not be additions, because the flat reference never sends them. @@ -337,13 +352,16 @@ def test_every_degraded_field_is_a_known_one(flat: Any, parent_child: Any) -> No ) -def test_der_additions_that_the_flat_reference_cannot_vouch_for(flat: Any, parent_child: Any) -> None: - """Pins the provisional set, so it shrinks deliberately rather than drifting. +def test_der_additions_are_provisional_or_attested_but_never_unexamined(flat: Any, parent_child: Any) -> None: + """Every DER addition is accounted for as one of exactly two things. + + Either the flat reference cannot vouch for it (`PROVISIONAL_DER`, because the + frozen simulator never sends BESS identity), or a capture from real firmware + has settled it (`ATTESTED_AGAINST_FIRMWARE`, which is how `pv.product_name` + turned out to be an identity users already have rather than something new). - These classify as additions only because the frozen flat simulator publishes - no BESS identity and almost no PV identity. If a flat capture ever arrives - from firmware with a BESS attached, this list should shrink and some members - will move to Severity 3 semantic changes instead. + A third option — an addition in neither list — means one appeared and nobody + asked which it was. """ additions: set[str] = set() for scope, before, after in ( @@ -353,9 +371,11 @@ def test_der_additions_that_the_flat_reference_cannot_vouch_for(flat: Any, paren found, _ = _classify(scope, before, after) additions |= found - assert additions == set(PROVISIONAL_DER), ( - f"the unattested DER addition set moved: {sorted(additions)}. Every member is a " - "field the flat simulator cannot vouch for; reconcile before treating it as new." + accounted = set(PROVISIONAL_DER) | set(ATTESTED_AGAINST_FIRMWARE) + assert additions == accounted, ( + f"the DER addition set moved: {sorted(additions)}. Each member is either a field " + "the frozen simulator cannot vouch for or one real firmware has settled; a new one " + "needs deciding which, because 'addition' is the bucket that hides a surviving entity." ) From 7b0d26cfe4274c85867ad50550e36bb9c8ce87ea Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:59:29 -0700 Subject: [PATCH 051/115] feat(schema_1): restore the islanding assertion, the one capability v1.0 lost set_dominant_power_source_topic() returned None on schema_1, so the write had nowhere to go on a v1.0 panel. The panel offered the control the whole time -- shed/asserted-islanding-state, settable=True, enum NONE/ON_GRID/OFF_GRID -- and the adapter simply never named it. Not a platform regression; an unwired successor. What that cost is specific. Communication to the BESS is lost, the grid comes back, and the user has no way to assert that it is up so the BESS stops discharging. Unavailable during an outage, which is when it is wanted. It is the only capability a user loses on migration, as against a value or an entity. The topic alone is not enough, which is the part worth reading. The client publishes the caller's value verbatim, and the published protocol speaks flat's vocabulary -- GRID, BATTERY, PV, GENERATOR, NONE, UNKNOWN -- while the panel accepts three assertion values. Forwarding 'GRID' would put a string outside the enum on the wire. So the adapter now names the payload as well as the topic, and the protocol carries dominant_power_source_payload alongside the topic method. The narrowing loses nothing real: six source classes were pressed into service as a manual override, and the override only ever needed on-grid, off-grid, or no assertion. An unrecognised value returns None and the transport raises, because asserting an islanding state the user did not ask for is worse than refusing -- this control tells a BESS whether to keep discharging. schema_0 implements the same method as the identity, so a caller never has to know which schema it is talking to. Same call, both schemas: flat core/dominant-power-source/set <- 'GRID' v1.0 shed/asserted-islanding-state/set <- 'ON_GRID' Falsified by forwarding the value unchanged, the plausible bug: 2 failed. The old test pinning the None behaviour is replaced rather than deleted, and the protocol conformance list gains the new method so it cannot go unguarded. 587 passed, mypy clean across 34 files. --- .../src/span_panel_api_schema_0/adapter.py | 17 ++++++ .../src/span_panel_api_schema_1/adapter.py | 52 +++++++++++++++---- .../src/span_panel_api_schema_1/const.py | 3 ++ src/span_panel_api/mqtt/client.py | 14 ++++- src/span_panel_api/protocol.py | 12 +++++ tests/test_protocol_conformance.py | 1 + tests/test_schema_one_adapter.py | 41 +++++++++++++-- 7 files changed, 125 insertions(+), 15 deletions(-) diff --git a/packages/schema-0/src/span_panel_api_schema_0/adapter.py b/packages/schema-0/src/span_panel_api_schema_0/adapter.py index f44ea14..1673c68 100644 --- a/packages/schema-0/src/span_panel_api_schema_0/adapter.py +++ b/packages/schema-0/src/span_panel_api_schema_0/adapter.py @@ -74,5 +74,22 @@ def set_dominant_power_source_topic(self) -> str | None: return None return PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=core_node, prop="dominant-power-source") + def dominant_power_source_payload(self, value: str) -> str | None: + """Flat speaks this vocabulary already, so the caller's value passes through. + + The method exists because `schema_1` has to translate — its successor + property accepts `NONE`/`ON_GRID`/`OFF_GRID`, not a source class — and a + caller should not have to know which schema it is talking to. Here the + translation is the identity. + + Validated rather than passed blindly: an unrecognised value returns None + and the transport refuses the command, which matches `schema_1`'s + behaviour and is better than putting a string outside the enum on the + wire. + """ + allowed = {"GRID", "BATTERY", "PV", "GENERATOR", "NONE", "UNKNOWN"} + candidate = value.strip().upper() + return candidate if candidate in allowed else None + def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]: return self._consumer.register_property_callback(callback) diff --git a/packages/schema-1/src/span_panel_api_schema_1/adapter.py b/packages/schema-1/src/span_panel_api_schema_1/adapter.py index 081c455..2a1a124 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/adapter.py +++ b/packages/schema-1/src/span_panel_api_schema_1/adapter.py @@ -27,7 +27,9 @@ HOMIE_VERSION, NODE_INFO, NODE_LOAD_SHED, + NODE_SHED, NODE_SWITCH, + PROP_ASSERTED_ISLANDING_STATE, PROP_MODEL, PROP_NAME, PROP_PRIORITY, @@ -197,16 +199,48 @@ def set_circuit_priority_topic(self, circuit_id: str) -> str: return self._set_topic(circuit_id, NODE_LOAD_SHED, PROP_PRIORITY) def set_dominant_power_source_topic(self) -> str | None: - """No v1.0 equivalent, so no topic. - - `dominant-power-source` split into `grid-forming-entity` and - `asserted-islanding-state`, which are different controls on different - devices rather than a renamed one. Returning None makes the transport - reject the command instead of publishing to a topic nothing serves — - and which successor to expose is a product decision, tracked in the - entity and config deltas write-up. + """The settable successor: `shed/asserted-islanding-state` on the panel. + + `dominant-power-source` split in two. The read half became + `grid/grid-forming-entity` on the MID; this is the write half, and it is + the only settable one, so it is what a caller of + `set_dominant_power_source` is reaching for. + + The catalog scopes it to exactly the case the control exists to serve: + "consulted only while the host has lost or degraded communication with + the device that senses that state (its MID / BESS)". Concretely — comms + to the BESS drop, the grid returns, the user asserts the grid is up, and + the BESS stops discharging. Returning None here, as this did until the + successor was decided, left that recovery unavailable during an outage. + + Payload translation is not optional: the flat enum this protocol speaks + is not the one the panel accepts. See `dominant_power_source_payload`. """ - return None + return self._set_topic(self._serial_number, NODE_SHED, PROP_ASSERTED_ISLANDING_STATE) + + def dominant_power_source_payload(self, value: str) -> str | None: + """Translate a flat `dominant-power-source` value into an assertion. + + The published protocol speaks flat's vocabulary — `GRID`, `BATTERY`, + `PV`, `GENERATOR`, `NONE`, `UNKNOWN` — because that is the contract + callers were written against. The panel accepts `NONE`, `ON_GRID`, + `OFF_GRID`. Publishing the caller's string unchanged would put a value + outside the enum on the wire. + + The narrowing loses nothing, because the six values were a *source + class* pressed into service as a manual override and the job only ever + needed on-grid, off-grid, or no assertion. Anything not recognised + returns None rather than guessing, so the transport refuses the command + instead of asserting something the user did not ask for. + """ + return { + "GRID": "ON_GRID", + "BATTERY": "OFF_GRID", + "PV": "OFF_GRID", + "GENERATOR": "OFF_GRID", + "NONE": "NONE", + "UNKNOWN": "NONE", + }.get(value.strip().upper()) def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]: """Subscribe to per-property updates; returns an unregister callable.""" diff --git a/packages/schema-1/src/span_panel_api_schema_1/const.py b/packages/schema-1/src/span_panel_api_schema_1/const.py index f0abeeb..bfeea8b 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/const.py +++ b/packages/schema-1/src/span_panel_api_schema_1/const.py @@ -49,6 +49,9 @@ PROP_RELAY_REQUESTER = "relay-requester" PROP_SPACES = "spaces" +# shed node +PROP_ASSERTED_ISLANDING_STATE = "asserted-islanding-state" + # Panel-level PROP_DATA_MODEL_VERSION = "data-model-version" PROP_FIRMWARE_VERSION = "firmware-version" diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 4caadd9..5667776 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -374,12 +374,22 @@ async def set_dominant_power_source(self, value: str) -> None: Args: value: DPS enum value (GRID, BATTERY, NONE, GENERATOR, PV) + + The adapter names both the topic and the payload, because the two + schemas do not accept the same values. Flat takes this vocabulary + directly; v1.0 routes the command to `shed/asserted-islanding-state`, + whose enum is `NONE`/`ON_GRID`/`OFF_GRID`. Publishing `value` unchanged + would put a string outside that enum on the wire. """ - topic = self._require_adapter().set_dominant_power_source_topic() + adapter = self._require_adapter() + topic = adapter.set_dominant_power_source_topic() if topic is None: raise SpanPanelServerError("Core node not found in panel topology") + payload = adapter.dominant_power_source_payload(value) + if payload is None: + raise SpanPanelServerError(f"{value!r} has no representation on this schema's control") if self._bridge is not None: - self._bridge.publish(topic, value, qos=1) + self._bridge.publish(topic, payload, qos=1) # -- StreamingCapableProtocol ------------------------------------------ diff --git a/src/span_panel_api/protocol.py b/src/span_panel_api/protocol.py index 4a4bae7..47f8a96 100644 --- a/src/span_panel_api/protocol.py +++ b/src/span_panel_api/protocol.py @@ -152,4 +152,16 @@ def set_circuit_priority_topic(self, circuit_id: str) -> str: ... def set_dominant_power_source_topic(self) -> str | None: ... + def dominant_power_source_payload(self, value: str) -> str | None: + """Translate a caller's value into what this schema's wire accepts. + + Callers speak the flat vocabulary (`GRID`, `BATTERY`, `PV`, `GENERATOR`, + `NONE`, `UNKNOWN`) because that is the published contract. Under v1.0 the + settable successor is `shed/asserted-islanding-state`, whose enum is + `NONE`/`ON_GRID`/`OFF_GRID`, so the value has to be mapped rather than + forwarded. Returning None means "no legal representation", and the + transport should refuse the command rather than publish a value the + panel will reject. + """ + def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]: ... diff --git a/tests/test_protocol_conformance.py b/tests/test_protocol_conformance.py index 5b64e81..330c8a3 100644 --- a/tests/test_protocol_conformance.py +++ b/tests/test_protocol_conformance.py @@ -63,6 +63,7 @@ def test_schema_adapter_declares_its_methods() -> None: "set_circuit_relay_topic", "set_circuit_priority_topic", "set_dominant_power_source_topic", + "dominant_power_source_payload", "register_property_callback", ): assert hasattr(SchemaAdapter, name), f"SchemaAdapter is missing method {name}" diff --git a/tests/test_schema_one_adapter.py b/tests/test_schema_one_adapter.py index 141f264..100aafe 100644 --- a/tests/test_schema_one_adapter.py +++ b/tests/test_schema_one_adapter.py @@ -282,10 +282,43 @@ def test_command_topics_address_the_child_device(adapter: SchemaOneAdapter) -> N assert adapter.set_circuit_priority_topic(SOLAR_CIRCUIT) == f"ebus/5/{SOLAR_CIRCUIT}/load-shed/priority/set" -def test_dominant_power_source_has_no_topic(adapter: SchemaOneAdapter) -> None: - """It split into two different controls on different devices. None makes - the transport reject the command rather than publish where nothing listens.""" - assert adapter.set_dominant_power_source_topic() is None +def test_dominant_power_source_writes_the_panel_assertion(adapter: SchemaOneAdapter) -> None: + """It split in two; this is the settable half, on the panel's shed node. + + Returned None until 2026-08-08, which left a real capability unreachable: + comms to the BESS drop, the grid returns, and the user has no way to assert + that it is up so the BESS stops discharging. The panel offered the control + the whole time — `shed/asserted-islanding-state`, `settable=True` — and the + adapter simply never named it. + """ + assert adapter.set_dominant_power_source_topic() == f"ebus/5/{PANEL}/shed/asserted-islanding-state/set" + + +def test_the_flat_vocabulary_is_translated_not_forwarded(adapter: SchemaOneAdapter) -> None: + """The published protocol speaks flat's enum; the panel accepts a different one. + + Forwarding the caller's string would publish a value outside + `NONE,ON_GRID,OFF_GRID` and the panel would reject it. The narrowing loses + nothing real: six *source classes* were pressed into service as a manual + override, and the override only ever needed on-grid, off-grid, or nothing. + """ + assert adapter.dominant_power_source_payload("GRID") == "ON_GRID" + + for off_grid in ("BATTERY", "PV", "GENERATOR"): + assert adapter.dominant_power_source_payload(off_grid) == "OFF_GRID", off_grid + + for no_assertion in ("NONE", "UNKNOWN"): + assert adapter.dominant_power_source_payload(no_assertion) == "NONE", no_assertion + + +def test_an_unrecognised_value_is_refused_rather_than_guessed(adapter: SchemaOneAdapter) -> None: + """None means "no legal representation", and the transport raises on it. + + Asserting an islanding state the user did not ask for is worse than refusing + the command, because this control tells a BESS whether to keep discharging. + """ + assert adapter.dominant_power_source_payload("SOLAR") is None + assert adapter.dominant_power_source_payload("") is None # --------------------------------------------------------------------------- From 54aa4dddc1923ef63bc039ee6d3362c9ff6c2f14 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:44:40 -0700 Subject: [PATCH 052/115] fix(schema_1): describe the five downstream-lugs fields, which had no metadata Class A of the survival analysis. feedthrough_power_w, the two feedthrough energy fields, and the two downstream currents were populated and carried no metadata, so schema_validation.py had nothing to check their units against -- five sensors with no guard against a silent unit change, in the region the lugs fidelity gap already makes least testable. Not the five-row table addition it was filed as. _PROPERTY_FIELD_MAP keys on (device type, node, property) and the two lugs devices match on all three -- same energy.ebus.device.lugs, same meter node, same five properties -- differing only in the info/direction value. One row per property is all the table can hold, and those rows go to the upstream paths. The snapshot mapper never had the problem because it resolves the pair by direction, which is why the values were right while the descriptions were missing. So the downstream fields resolve through find_lugs, the same function the snapshot mapper uses, layered over the table rather than replacing it. Sharing the resolver is the point: the metadata and the value cannot disagree about which device is which. 42 metadata rows -> 47, upstream untouched. The test boundary is worth reading, because I found it by mutation rather than by reasoning. Swapping upstream=False for upstream=True passes every assertion: both lugs declare byte-identical meter metadata, same properties and same units, so a correct resolution and a swapped one produce the same output. That is section 5.3 of the survival analysis reappearing one layer up, and no assertion at this layer can close it while the two devices are indistinguishable. What is checkable is that the fields come from a resolved device rather than leaking out of the table, so a tree with no downstream lugs must not describe them. That test is added and the limitation is stated in its docstring rather than left for the next reader to trip over. Falsified: removing the new lookup fails, 1 failed / 27 passed. 590 passed overall, mypy clean. --- .../span_panel_api_schema_1/field_metadata.py | 51 +++++++++++++ tests/test_schema_one_adapter.py | 71 +++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index 911d053..500a7fe 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -29,6 +29,9 @@ NODE_SOC, NODE_STATUS, NODE_SWITCH, + PROP_ACTIVE_POWER, + PROP_EXPORTED_ENERGY, + PROP_IMPORTED_ENERGY, TYPE_BESS, TYPE_CIRCUIT, TYPE_EVSE, @@ -36,6 +39,8 @@ TYPE_PANEL, TYPE_PV, ) +from span_panel_api_schema_1.panel import PROP_CURRENT_A, PROP_CURRENT_B, find_lugs +from span_panel_api_schema_1.snapshot import device_type as declared_type if TYPE_CHECKING: from ebus_sdk.homie import DiscoveredDevice @@ -127,9 +132,55 @@ def build_field_metadata(devices: list[DiscoveredDevice]) -> dict[str, FieldMeta if found is not None: unit, datatype = found metadata[field_path] = FieldMetadata(unit=unit, datatype=datatype) + metadata.update(_downstream_lugs_metadata(devices)) return metadata +_DOWNSTREAM_LUGS_FIELDS: tuple[tuple[str, str], ...] = ( + (PROP_ACTIVE_POWER, "panel.feedthrough_power_w"), + (PROP_IMPORTED_ENERGY, "panel.feedthrough_energy_consumed_wh"), + (PROP_EXPORTED_ENERGY, "panel.feedthrough_energy_produced_wh"), + (PROP_CURRENT_A, "panel.downstream_l1_current_a"), + (PROP_CURRENT_B, "panel.downstream_l2_current_a"), +) +"""The five fields the table above cannot address, and why it cannot. + +`_PROPERTY_FIELD_MAP` is keyed `(device type, node, property)`, and the two lugs +devices share all three — same `energy.ebus.device.lugs`, same `meter` node, same +properties — differing only in the `info/direction` value. So one row per property +is all the table can hold, and those rows go to the `upstream_*` paths. + +The snapshot mapper has never had this problem, because it resolves the two +devices by direction and reads each. That is why these five fields are *populated* +and yet carry no metadata: the values were right, and `schema_validation.py` had +nothing to check their units against — five sensors with no guard against a silent +unit change, in exactly the region the lugs fidelity gap makes least testable. +""" + + +def _downstream_lugs_metadata(devices: list[DiscoveredDevice]) -> dict[str, FieldMetadata]: + """Metadata for the downstream lugs, resolved by direction rather than by type. + + Uses the same `find_lugs` the snapshot mapper uses, so the metadata and the + value can never disagree about which device is which. + """ + downstream = find_lugs([d for d in devices if declared_type(d).startswith(TYPE_LUGS)], upstream=False) + if downstream is None: + return {} + + declared = _properties(_nodes(downstream.description or {}).get(NODE_METER, {})) + found: dict[str, FieldMetadata] = {} + for property_id, field_path in _DOWNSTREAM_LUGS_FIELDS: + definition = declared.get(property_id) + if definition is None: + continue + found[field_path] = FieldMetadata( + unit=_optional_str(definition.get("unit")), + datatype=str(definition.get("datatype") or "string"), + ) + return found + + def _lookup( declared: dict[str, tuple[str | None, str]], device_type: str, node_id: str, property_id: str ) -> tuple[str | None, str] | None: diff --git a/tests/test_schema_one_adapter.py b/tests/test_schema_one_adapter.py index 100aafe..83d5cfa 100644 --- a/tests/test_schema_one_adapter.py +++ b/tests/test_schema_one_adapter.py @@ -256,6 +256,77 @@ def test_no_property_declares_an_abstract_unit() -> None: assert not declared & abstract, f"abstract unit tokens in the captured tree: {sorted(declared & abstract)}" +def test_the_downstream_lugs_fields_carry_metadata(adapter: SchemaOneAdapter) -> None: + """Five fields were populated with no metadata behind them until 2026-08-08. + + `_PROPERTY_FIELD_MAP` keys on (device type, node, property), and the two lugs + devices match on all three — same `energy.ebus.device.lugs`, same `meter` + node, same properties — so one row per property is all it can hold, and those + rows went to the `upstream_*` paths. The snapshot mapper never had the problem + because it resolves the pair by `info/direction`. + + The consequence was not a wrong value but an absent guard: + `schema_validation.py` cross-checks the integration's declared unit against + this table, so five sensors had nothing to check against — and they are the + feedthrough readings, which the lugs fidelity gap already makes the least + testable part of the surface. + """ + metadata = adapter.build_field_metadata() + + assert metadata["panel.feedthrough_power_w"].unit == "W" + assert metadata["panel.feedthrough_energy_consumed_wh"].unit == "Wh" + assert metadata["panel.feedthrough_energy_produced_wh"].unit == "Wh" + assert metadata["panel.downstream_l1_current_a"].unit == "A" + assert metadata["panel.downstream_l2_current_a"].unit == "A" + + +def test_the_upstream_lugs_fields_are_not_displaced(adapter: SchemaOneAdapter) -> None: + """Held separately because the two lugs resolve through different paths now. + + The upstream fields come from the table; the downstream ones from a + direction-resolved lookup layered over it. A change that made the second + overwrite the first would leave both sets present and both describing the + same device, which reads as working. + """ + metadata = adapter.build_field_metadata() + + assert metadata["panel.upstream_l1_current_a"].unit == "A" + assert metadata["panel.upstream_l2_current_a"].unit == "A" + assert metadata["panel.instant_grid_power_w"].unit == "W" + + +def test_the_downstream_fields_need_a_downstream_device() -> None: + """The strongest available check, and worth saying why it is not stronger. + + **Which** lugs device the lookup resolves cannot be asserted here. Both + declare byte-identical `meter` metadata — same five properties, same units — + so swapping `upstream=False` for `upstream=True` produces exactly the same + result, verified by mutation. That is the fidelity gap §5.3 of the survival + analysis records, reappearing one layer up: with the two devices + indistinguishable, a correct resolution and a swapped one are the same + output. + + What *is* checkable is that these fields come from a resolved device at all + rather than leaking out of the table. Feed a tree with no downstream lugs and + they must be absent, while the upstream fields survive untouched. + """ + adapter = SchemaOneAdapter(PANEL, _schema()) + _feed(adapter, device_ids=[device for device in _TREE if device != "lugs-downstream"]) + + metadata = adapter.build_field_metadata() + + for absent in ( + "panel.feedthrough_power_w", + "panel.feedthrough_energy_consumed_wh", + "panel.feedthrough_energy_produced_wh", + "panel.downstream_l1_current_a", + "panel.downstream_l2_current_a", + ): + assert absent not in metadata, f"{absent} was described with no device to describe" + + assert metadata["panel.upstream_l1_current_a"].unit == "A" + + def test_field_metadata_omits_fields_the_mapper_declines(adapter: SchemaOneAdapter) -> None: """Advertising a unit for a reading that never arrives would have the integration validate against a field nothing populates.""" From 0804aa65e1fc459bf4f0b99d0366815de10de76e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:14:25 -0700 Subject: [PATCH 053/115] fix(schema_1): describe battery.serial_number, and correct what Class B was Class B of the survival analysis recorded battery.serial_number and battery.software_version as having "no mapping anywhere" and being "simply never picked up". Both wrong. build_battery reads each of them and always has: serial_number=_optional(text(bess, NODE_INFO, PROP_SERIAL_NUMBER)), software_version=_optional(text(bess, NODE_INFO, PROP_FIRMWARE_VERSION)), What was missing was the value. The producer of the day published no BESS identity at all, so both read None, and an unpopulated field was read as an unmapped one. The snapshot already carries serial_number today -- the producer publishes info/serial-number since the fidelity work. So this was Class A's problem wearing Class B's label: a missing metadata row, not a missing mapping. The row is added; battery.serial_number now describes as string with no unit, and schema_validation.py has something to check against. 47 rows -> 48. software_version deliberately gets no row. The BESS declares info/firmware-version and never sends a value -- the residual half of 5.2 -- and describing it would advertise a reading that never arrives, which is the one thing the metadata builder's docstring refuses to do. It stays absent until the producer publishes, and test_the_ders_still_declare_two_identity_fields_they_never_publish fails when that changes, so the two cannot drift apart. Checked python-sdk#27 first, as it was the open question against this work. It does not bear on it: it concerns ebus_default_override / _EBUS_DEFAULTS drifting from the catalogs, and neither that table nor device_class/state_class appears anywhere in our packages. Our FieldMetadata carries unit and datatype only, read from each device's own $description; the integration owns the HA classes. Its adjacent consequence -- soe and friends moving total_increasing -> measurement in 0.18.0 -- was already assessed in the delta document as not reaching us. Falsified by removing the row: 1 failed, 28 passed. 591 passed, mypy clean. --- .../span_panel_api_schema_1/field_metadata.py | 1 + tests/test_schema_one_adapter.py | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index 500a7fe..025cac9 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -94,6 +94,7 @@ (TYPE_BESS, NODE_INFO, "vendor-name", "battery.vendor_name"), (TYPE_BESS, NODE_INFO, "model", "battery.product_name"), (TYPE_BESS, NODE_INFO, "part-number", "battery.model"), + (TYPE_BESS, NODE_INFO, "serial-number", "battery.serial_number"), (TYPE_BESS, NODE_INFO, "nameplate-capacity", "battery.nameplate_capacity_kwh"), # --- PV ------------------------------------------------------------------ (TYPE_PV, NODE_INFO, "vendor-name", "pv.vendor_name"), diff --git a/tests/test_schema_one_adapter.py b/tests/test_schema_one_adapter.py index 83d5cfa..2c0e3cb 100644 --- a/tests/test_schema_one_adapter.py +++ b/tests/test_schema_one_adapter.py @@ -295,6 +295,33 @@ def test_the_upstream_lugs_fields_are_not_displaced(adapter: SchemaOneAdapter) - assert metadata["panel.instant_grid_power_w"].unit == "W" +def test_the_bess_serial_is_described_and_its_firmware_is_not(adapter: SchemaOneAdapter) -> None: + """Class B of the survival analysis, which was misdiagnosed. + + It recorded `battery.serial_number` and `battery.software_version` as having + "no mapping at all… simply never picked up". `build_battery` has always read + both. What was missing was the *value*: the producer of the day published no + BESS identity, so both read `None`, and an unpopulated field was mistaken for + an unmapped one. + + So the gap was a metadata row, not a mapping. `serial_number` gets one — the + BESS declares it and now publishes it. + + `software_version` deliberately does not. The BESS declares + `info/firmware-version` and never sends a value, which is the residual half of + §5.2, and describing it would advertise a reading that never arrives — the + exact failure the metadata builder's own docstring refuses to commit. It stays + absent until the producer publishes it, and + `test_the_ders_still_declare_two_identity_fields_they_never_publish` fails when + that changes. + """ + metadata = adapter.build_field_metadata() + + assert metadata["battery.serial_number"].datatype == "string" + assert metadata["battery.serial_number"].unit is None + assert "battery.software_version" not in metadata + + def test_the_downstream_fields_need_a_downstream_device() -> None: """The strongest available check, and worth saying why it is not stronger. From 8a7ea17254d546258ea0abdad0cb7af6c9f9e3f8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:21:37 -0700 Subject: [PATCH 054/115] test(schema_1): re-vendor against corrected device ids, and record what that exposed panelbench 38fb634 corrected six device ids that followed no panel's convention -- `lugs-upstream` / `lugs-downstream` for `-lugs-{up,dn}`, and bare `bess` / `pv` / `evse` / `evse-2` for `-`. Re-vendored the two peer captures byte-for-byte and moved peer.commit 43ec5b0 -> 38fb634. `tests/fixtures/parent_child_tree.json` is deliberately NOT re-vendored. It is a capture of the upstream *reference* example (`example-40t-001`, 13 devices), whose ids are upstream's and legitimately unchanged -- the device-id baseline over there still records its six divergences. Only the two peer fixtures track panelbench. Two tests moved as a result. `test_the_ders_still_declare_two_identity_fields_they_never_publish` iterated the literal ids `("bess", "pv", "evse", "evse-2")` and raised KeyError. Now keyed by device *type*, which is both stable across config changes and what the mapper itself resolves on -- an id-keyed test asserts an assumption the production code does not make. Collapsing two devices of one type into a single key would hide a disagreement between them, so that case asserts rather than overwrites. The second is the finding. flat.evse: {'evse', 'evse-2'} parent_child.evse: {'sim-40t-001-SIM-EVSE-001', 'sim-40t-001-SIM-EVSE-002'} Disjoint. Flat keys an EVSE by node name; v1.0 keys it by the proxied device id the migration guide specifies. EVSE is one of only two device classes whose id reaches a snapshot key -- circuits are the other, and those are identical across the migration, which is the fact the whole harness rests on. **This was invisible until now, and the reason is the point.** The v1.0 producer published flat-shaped ids inherited from an example script, so both sides matched, the premise check passed, and EVSE identity looked exactly as safe as circuit identity. The comparison was rigged reassuring by a producer detail. Correcting the producer made the two sides disagree, which is the true state. `test_both_captures_describe_the_same_logical_panel` asserted `set(flat.evse) == set(parent_child.evse)` as a *premise*. That is the wrong place for it now: the keys legitimately differ, so a real delta was sitting where a broken-fixture check reads. The premise now compares counts -- the configuration-parity fact it was there for -- and `test_evse_identity_does_not_survive_the_migration` holds the delta, asserted rather than left to a document. What that test establishes and does not, stated in it rather than implied: it establishes the snapshot key changes, and that key is what an EVSE entity's identity is built from here. It does NOT establish a user loses EVSE history -- the integration still pins 2.6.4, is not on this adapter, and how it derives an EVSE `unique_id` cannot be read from this repository. The flat side is unattested too: the frozen simulator supplies it and the one live panel available has no Drives. So this is a finding pending firmware confirmation, not a settled break. Falsified by reverting the ids in the vendored capture consistently -- keys plus the `children` / `parent` references, the way a real revert would -- which puts the two sides back in overlap and fails the guard with its intended message. A bare key rename does not work: the tree stays internally consistent and simply never reaches ready, which is worth knowing for the next person who tries it. 592 passed, mypy clean, ruff clean. Three files unrelated to this change carry pre-existing format drift and were left alone. --- .../spec/fixtures/simulator_tree.json | 1442 ++++++++--------- .../spec/fixtures/simulator_wire.json | 332 ++-- .../span_panel_api_schema_1/spec_lock.json | 2 +- tests/test_schema_migration_delta.py | 61 +- tests/test_schema_one_against_simulator.py | 37 +- 5 files changed, 972 insertions(+), 902 deletions(-) diff --git a/packages/schema-1/spec/fixtures/simulator_tree.json b/packages/schema-1/spec/fixtures/simulator_tree.json index cfccd8b..5313ebf 100644 --- a/packages/schema-1/spec/fixtures/simulator_tree.json +++ b/packages/schema-1/spec/fixtures/simulator_tree.json @@ -135,7 +135,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193480 + "version": 1786394128456 }, "1bfdc7ecebb0547bbe87a3696cddb0c0": { "children": [], @@ -273,7 +273,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193483 + "version": 1786394128459 }, "1eeeb748eeaa58edb7e9b7e9dbbdeca7": { "children": [], @@ -411,7 +411,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193482 + "version": 1786394128458 }, "2140a7e253ed54e3bc90a959081df615": { "children": [], @@ -549,7 +549,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193481 + "version": 1786394128457 }, "249a2f59782e5f1ab317c4632e79afad": { "children": [], @@ -687,7 +687,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193483 + "version": 1786394128459 }, "3d9d86f303cc50d1827be57d4c667e53": { "children": [], @@ -825,7 +825,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193479 + "version": 1786394128455 }, "3eeb0eb1605e5a7eadac41994b7a096c": { "children": [], @@ -963,7 +963,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193479 + "version": 1786394128455 }, "43a0521737db516f99f14a9964ea4af0": { "children": [], @@ -1101,7 +1101,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193481 + "version": 1786394128457 }, "4aeb08c46c2c5905a944166413f2f1ef": { "children": [], @@ -1239,7 +1239,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193482 + "version": 1786394128458 }, "4ce8b30e8d3f5c49b9e0ab0c8caf4832": { "children": [], @@ -1377,7 +1377,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193483 + "version": 1786394128459 }, "4d1deb6acb065746b13207b1358f8ca7": { "children": [], @@ -1515,7 +1515,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193481 + "version": 1786394128457 }, "516694a326a35cd88600b3520e8a981a": { "children": [], @@ -1653,7 +1653,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193482 + "version": 1786394128458 }, "6fcb352679ad5bfb8c8a8eab06829b9f": { "children": [], @@ -1791,7 +1791,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193483 + "version": 1786394128459 }, "770e2de52c33508a8a9ee8878064b46f": { "children": [], @@ -1929,7 +1929,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193479 + "version": 1786394128455 }, "80a4fada833156ab8112f9d50e252b8f": { "children": [], @@ -2067,7 +2067,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193480 + "version": 1786394128456 }, "9429f828509e58d59cb5f0f9f5fee523": { "children": [], @@ -2205,7 +2205,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193479 + "version": 1786394128455 }, "948dea7788aa5c959b99df0edfabead2": { "children": [], @@ -2343,7 +2343,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193482 + "version": 1786394128458 }, "af731c49a6785a4cb2ea5549fb8bce7e": { "children": [], @@ -2481,7 +2481,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193482 + "version": 1786394128458 }, "afe90839f2725e3e962fb05afa2b6d43": { "children": [], @@ -2619,7 +2619,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193481 + "version": 1786394128457 }, "b24483358d29589d8e91d3bf11113269": { "children": [], @@ -2757,7 +2757,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193480 + "version": 1786394128456 }, "b9fa08f1eaaf5d129bd5c78e1d5d937f": { "children": [], @@ -2895,7 +2895,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193484 + "version": 1786394128459 }, "be7742043a06554aab2a1e38cc776603": { "children": [], @@ -3033,148 +3033,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193483 - }, - "bess": { - "children": [ - "bess-mid" - ], - "extensions": [], - "homie": "5.0", - "name": "Battery", - "nodes": { - "info": { - "name": "info", - "properties": { - "firmware-version": { - "datatype": "string", - "name": "Firmware version" - }, - "model": { - "datatype": "string", - "name": "Model" - }, - "nameplate-capacity": { - "datatype": "float", - "name": "Nameplate capacity", - "unit": "kWh" - }, - "part-number": { - "datatype": "string", - "name": "Part number" - }, - "serial-number": { - "datatype": "string", - "name": "Serial number" - }, - "vendor-name": { - "datatype": "string", - "name": "Vendor name" - } - }, - "type": "energy.ebus.capability.info" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Active power", - "unit": "W" - } - }, - "type": "energy.ebus.capability.meter" - }, - "soc": { - "name": "soc", - "properties": { - "soc": { - "datatype": "float", - "name": "State of charge", - "unit": "%" - }, - "soe": { - "datatype": "float", - "name": "State of energy", - "unit": "kWh" - } - }, - "type": "energy.ebus.capability.soc" - }, - "status": { - "name": "status", - "properties": { - "communication-state": { - "datatype": "enum", - "format": "OK,DEGRADED,LOST,UNKNOWN", - "name": "Communication state" - } - }, - "type": "energy.ebus.capability.status" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.bess", - "version": 1786157193484 - }, - "bess-mid": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Microgrid Interconnect Device", - "nodes": { - "grid": { - "name": "grid", - "properties": { - "grid-forming-entity": { - "datatype": "string", - "name": "Identity of the currently grid-forming entity" - }, - "grid-state": { - "datatype": "enum", - "format": "UP,DOWN,DEGRADED,UNKNOWN", - "name": "Sensed grid condition" - }, - "islanding-state": { - "datatype": "enum", - "format": "ON_GRID,OFF_GRID,UNKNOWN", - "name": "Islanding state of the BESS-integrated grid-forming device" - } - }, - "type": "energy.ebus.capability.grid" - }, - "info": { - "name": "info", - "properties": { - "firmware-version": { - "datatype": "string", - "name": "Firmware version" - }, - "hardware-version": { - "datatype": "string", - "name": "Hardware version" - }, - "model": { - "datatype": "string", - "name": "Model" - }, - "serial-number": { - "datatype": "string", - "name": "Serial number" - }, - "vendor-name": { - "datatype": "string", - "name": "Vendor name" - } - }, - "type": "energy.ebus.capability.info" - } - }, - "parent": "bess", - "root": "sim-40t-001", - "type": "energy.ebus.device.mid", - "version": 1786157193484 + "version": 1786394128459 }, "c058aa11287f50f9b81e5160a0678869": { "children": [], @@ -3312,7 +3171,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193479 + "version": 1786394128455 }, "c339ec7ce7ff521ca7646f9606baff9f": { "children": [], @@ -3450,7 +3309,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193481 + "version": 1786394128457 }, "d1ff145887a05b839ede89409c27b398": { "children": [], @@ -3588,7 +3447,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193480 + "version": 1786394128456 }, "e0ac90e169e6550ea83fe0b1942f1d0e": { "children": [], @@ -3726,7 +3585,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193480 + "version": 1786394128456 }, "e0bc156c85015a609d4132084dfcd6fe": { "children": [], @@ -3864,7 +3723,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193481 + "version": 1786394128457 }, "edee3425d50d51ffb022ee999053b2b4": { "children": [], @@ -4002,7 +3861,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193480 + "version": 1786394128456 }, "ef972f063451539e8b2ad88e831d87b6": { "children": [], @@ -4140,174 +3999,136 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786157193482 + "version": 1786394128458 }, - "evse": { + "f515a0f43b6555b1a196fbb62728c24e": { "children": [], "extensions": [], "homie": "5.0", - "name": "SPAN Drive - Garage", + "name": "Exterior Lights", "nodes": { - "config": { - "name": "config", + "breaker": { + "name": "breaker", "properties": { - "max-charge-current": { + "poles": { "datatype": "integer", - "name": "Commissioned maximum EVSE charge current (installer-configured)", - "unit": "A" + "format": "1:4:1", + "name": "Number of breaker poles" }, - "user-max-charge-current": { + "rating": { "datatype": "integer", - "name": "User-configured maximum EVSE charge current (ceiling)", - "settable": true, + "name": "Circuit breaker rating", "unit": "A" } }, - "type": "energy.ebus.capability.config" + "type": "energy.ebus.capability.breaker" }, - "info": { - "name": "info", + "connection": { + "name": "connection", "properties": { - "firmware-version": { - "datatype": "string", - "name": "Firmware version" + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" }, - "model": { + "feeds-device-id": { "datatype": "string", - "name": "Model" + "name": "Homie device-id of the downstream device fed by this circuit" }, - "part-number": { - "datatype": "string", - "name": "Part number" + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" }, - "serial-number": { + "feeds-device-type": { "datatype": "string", - "name": "Serial number" + "name": "Homie $type of the downstream device" + } + }, + "type": "energy.ebus.capability.connection" + }, + "info": { + "name": "info", + "properties": { + "name": { + "datatype": "string", + "name": "Circuit name" }, - "vendor-name": { + "spaces": { "datatype": "string", - "name": "Vendor name" + "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" } }, "type": "energy.ebus.capability.info" }, + "load-shed": { + "name": "load-shed", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", + "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", + "settable": true + } + }, + "type": "energy.ebus.capability.load-shed" + }, "meter": { "name": "meter", "properties": { - "advertised-current": { + "active-power": { "datatype": "float", - "name": "Current EVSE is advertising to the EV", + "name": "Measured active power", + "unit": "W" + }, + "current": { + "datatype": "float", + "name": "Measured current", "unit": "A" + }, + "exported-energy": { + "datatype": "float", + "name": "Measured energy exported", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Measured energy imported", + "unit": "Wh" } }, "type": "energy.ebus.capability.meter" }, - "status": { - "name": "status", + "pcs": { + "name": "pcs", "properties": { - "status": { - "datatype": "enum", - "format": "AVAILABLE,PREPARING,CHARGING,UNAVAILABLE", - "name": "Status" + "managed": { + "datatype": "boolean", + "name": "Is circuit managed by PCS?" + }, + "priority": { + "datatype": "integer", + "name": "Circuit PCS priority ranking" } }, - "type": "energy.ebus.capability.status" + "type": "energy.ebus.capability.pcs" }, "switch": { "name": "switch", "properties": { - "lock-state": { + "relay": { "datatype": "enum", - "format": "UNLOCKED,LOCKED", - "name": "Lock state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.evse", - "version": 1786157193484 - }, - "evse-2": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "SPAN Drive - Driveway", - "nodes": { - "config": { - "name": "config", - "properties": { - "max-charge-current": { - "datatype": "integer", - "name": "Commissioned maximum EVSE charge current (installer-configured)", - "unit": "A" - }, - "user-max-charge-current": { - "datatype": "integer", - "name": "User-configured maximum EVSE charge current (ceiling)", - "settable": true, - "unit": "A" - } - }, - "type": "energy.ebus.capability.config" - }, - "info": { - "name": "info", - "properties": { - "firmware-version": { - "datatype": "string", - "name": "Firmware version" - }, - "model": { - "datatype": "string", - "name": "Model" - }, - "part-number": { - "datatype": "string", - "name": "Part number" + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Circuit relay state", + "settable": true }, - "serial-number": { - "datatype": "string", - "name": "Serial number" + "relay-controllable": { + "datatype": "boolean", + "name": "Can the circuit's relay be commanded by the user?" }, - "vendor-name": { - "datatype": "string", - "name": "Vendor name" - } - }, - "type": "energy.ebus.capability.info" - }, - "meter": { - "name": "meter", - "properties": { - "advertised-current": { - "datatype": "float", - "name": "Current EVSE is advertising to the EV", - "unit": "A" - } - }, - "type": "energy.ebus.capability.meter" - }, - "status": { - "name": "status", - "properties": { - "status": { - "datatype": "enum", - "format": "AVAILABLE,PREPARING,CHARGING,UNAVAILABLE", - "name": "Status" - } - }, - "type": "energy.ebus.capability.status" - }, - "switch": { - "name": "switch", - "properties": { - "lock-state": { + "relay-requester": { "datatype": "enum", - "format": "UNLOCKED,LOCKED", - "name": "Lock state" + "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", + "name": "Actor requesting the relay state" } }, "type": "energy.ebus.capability.switch" @@ -4315,102 +4136,117 @@ }, "parent": "sim-40t-001", "root": "sim-40t-001", - "type": "energy.ebus.device.evse", - "version": 1786157193484 + "type": "energy.ebus.device.circuit", + "version": 1786394128455 }, - "f515a0f43b6555b1a196fbb62728c24e": { - "children": [], + "sim-40t-001": { + "children": [ + "sim-40t-001-SIM-BESS-40T-001", + "770e2de52c33508a8a9ee8878064b46f", + "9429f828509e58d59cb5f0f9f5fee523", + "3d9d86f303cc50d1827be57d4c667e53", + "c058aa11287f50f9b81e5160a0678869", + "f515a0f43b6555b1a196fbb62728c24e", + "3eeb0eb1605e5a7eadac41994b7a096c", + "e0ac90e169e6550ea83fe0b1942f1d0e", + "80a4fada833156ab8112f9d50e252b8f", + "13044bfbcbe5554b8f3dba126bce828f", + "b24483358d29589d8e91d3bf11113269", + "d1ff145887a05b839ede89409c27b398", + "edee3425d50d51ffb022ee999053b2b4", + "c339ec7ce7ff521ca7646f9606baff9f", + "2140a7e253ed54e3bc90a959081df615", + "4d1deb6acb065746b13207b1358f8ca7", + "43a0521737db516f99f14a9964ea4af0", + "e0bc156c85015a609d4132084dfcd6fe", + "afe90839f2725e3e962fb05afa2b6d43", + "4aeb08c46c2c5905a944166413f2f1ef", + "516694a326a35cd88600b3520e8a981a", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7", + "ef972f063451539e8b2ad88e831d87b6", + "af731c49a6785a4cb2ea5549fb8bce7e", + "948dea7788aa5c959b99df0edfabead2", + "be7742043a06554aab2a1e38cc776603", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832", + "249a2f59782e5f1ab317c4632e79afad", + "1bfdc7ecebb0547bbe87a3696cddb0c0", + "6fcb352679ad5bfb8c8a8eab06829b9f", + "b9fa08f1eaaf5d129bd5c78e1d5d937f", + "sim-40t-001-SIM-EVSE-001", + "sim-40t-001-SIM-EVSE-002", + "sim-40t-001-lugs-up", + "sim-40t-001-lugs-dn", + "sim-40t-001-pv-1" + ], "extensions": [], "homie": "5.0", - "name": "Exterior Lights", + "name": "Span Panel", "nodes": { "breaker": { "name": "breaker", "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, "rating": { "datatype": "integer", - "name": "Circuit breaker rating", + "name": "Main breaker rating", "unit": "A" } }, "type": "energy.ebus.capability.breaker" }, - "connection": { - "name": "connection", + "door": { + "name": "door", "properties": { - "count": { - "datatype": "integer", - "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" - }, - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { + "state": { "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Door state" } }, - "type": "energy.ebus.capability.connection" + "type": "energy.ebus.capability.door" }, "info": { "name": "info", "properties": { - "name": { + "data-model-version": { "datatype": "string", - "name": "Circuit name" + "name": "eBus data-model version (parent/child schema discriminator)" }, - "spaces": { + "firmware-version": { "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { + "name": "Firmware version" + }, + "hardware-version": { + "datatype": "string", + "name": "Hardware version" + }, + "model": { "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true + "format": "MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48", + "name": "Model" + }, + "serial-number": { + "datatype": "string", + "name": "Serial number" + }, + "vendor-name": { + "datatype": "string", + "name": "Vendor name" } }, - "type": "energy.ebus.capability.load-shed" + "type": "energy.ebus.capability.info" }, "meter": { "name": "meter", "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { + "voltage-a": { "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" + "name": "L1 voltage", + "unit": "V" }, - "imported-energy": { + "voltage-b": { "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" + "name": "L2 voltage", + "unit": "V" } }, "type": "energy.ebus.capability.meter" @@ -4418,183 +4254,232 @@ "pcs": { "name": "pcs", "properties": { - "managed": { + "active": { "datatype": "boolean", - "name": "Is circuit managed by PCS?" + "name": "PCS system actively controlling one (or more) loads" }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { + "binding-constraint": { "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true + "format": "FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN", + "name": "Which constraint class currently sets the import limit" }, - "relay-controllable": { + "enabled": { "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" + "name": "PCS system enabled" }, - "relay-requester": { + "feed-import-limit": { + "datatype": "float", + "name": "Limit of maximum power feeding the distribution enclosure", + "unit": "A" + }, + "feed-import-limit-active": { + "datatype": "boolean", + "name": "Is feed-import-limit currently being enforced?" + }, + "feed-import-limit-enablement": { "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1786157193479 - }, - "lugs-downstream": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Downstream lugs", - "nodes": { - "connection": { - "name": "connection", - "properties": { - "count": { - "datatype": "integer", - "name": "Number of physical units aggregated up/downstream" + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", + "name": "Enablement status of the feed-import-limit" }, - "fed-by-device-id": { - "datatype": "string", - "name": "Homie device-id of the upstream device feeding this lugs" + "import-limit": { + "datatype": "float", + "name": "The power import limit currently being managed to", + "unit": "A" }, - "fed-by-device-status": { + "off-grid-import-limit": { + "datatype": "float", + "name": "Off-Grid limit maximum import power", + "unit": "A" + }, + "off-grid-import-limit-active": { + "datatype": "boolean", + "name": "Is off-grid-import-limit currently being enforced?" + }, + "off-grid-import-limit-enablement": { "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the upstream device" + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", + "name": "Enablement status of the off-grid-import-limit" }, - "fed-by-device-type": { - "datatype": "string", - "name": "Homie $type of the upstream device" + "operator-import-limit": { + "datatype": "float", + "name": "Operator-imposed maximum import limit", + "unit": "A" }, - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this lugs" + "operator-import-limit-active": { + "datatype": "boolean", + "name": "Is operator-import-limit currently being enforced?" }, - "feeds-device-status": { + "operator-import-limit-enablement": { "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", + "name": "Enablement status of the operator-import-limit" }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "direction": { + "requested-import-limit": { + "datatype": "float", + "name": "Requested limit maximum import power", + "unit": "A" + }, + "requested-import-limit-active": { + "datatype": "boolean", + "name": "Is requested-import-limit currently being enforced?" + }, + "requested-import-limit-enablement": { "datatype": "enum", - "format": "UPSTREAM,DOWNSTREAM", - "name": "Lugs feed direction: upstream or downstream" + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", + "name": "Enablement status of the requested-import-limit" } }, - "type": "energy.ebus.capability.info" + "type": "energy.ebus.capability.pcs" }, - "meter": { - "name": "meter", + "power-flows": { + "name": "power-flows", "properties": { - "active-power": { + "battery": { "datatype": "float", - "name": "Active power", + "name": "Battery/BESS power flow", "unit": "W" }, - "current-a": { + "grid": { "datatype": "float", - "name": "L1 current", - "unit": "A" + "name": "Grid power flow", + "unit": "W" }, - "current-b": { + "pv": { "datatype": "float", - "name": "L2 current", - "unit": "A" + "name": "PV power flow", + "unit": "W" }, - "exported-energy": { + "site": { "datatype": "float", - "name": "Exported energy", - "unit": "Wh" + "name": "Site power flow", + "unit": "W" + } + }, + "type": "energy.ebus.capability.power-flows" + }, + "shed": { + "name": "shed", + "properties": { + "asserted-islanding-state": { + "datatype": "enum", + "format": "NONE,ON_GRID,OFF_GRID", + "name": "Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)", + "settable": true }, - "imported-energy": { - "datatype": "float", - "name": "Imported energy", - "unit": "Wh" + "policy": { + "datatype": "json", + "format": "{\"$id\":\"soc-priority.v1\",\"type\":\"object\",\"required\":[\"algorithm\",\"parameters\"],\"additionalProperties\":false,\"properties\":{\"algorithm\":{\"const\":\"soc-priority.v1\"},\"parameters\":{\"type\":\"object\",\"required\":[\"soc-threshold-shed\",\"soc-threshold-release\"],\"additionalProperties\":false,\"properties\":{\"soc-threshold-shed\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":100,\"description\":\"SoC percent below which SOC_THRESHOLD circuits shed\"},\"soc-threshold-release\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":100,\"description\":\"SoC percent above which shed SOC_THRESHOLD circuits restore\"}}}}}", + "name": "Shed policy (algorithm and parameters)" } }, - "type": "energy.ebus.capability.meter" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.lugs", - "version": 1786157193484 - }, - "lugs-upstream": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Upstream lugs", - "nodes": { - "connection": { - "name": "connection", + "type": "energy.ebus.capability.shed" + }, + "shed-forecast": { + "name": "shed-forecast", "properties": { - "count": { + "confidence": { + "datatype": "enum", + "format": "LOW,MEDIUM,HIGH", + "name": "Confidence of the shed-forecast estimate" + }, + "full-charge-time-to-priority-shed": { "datatype": "integer", - "name": "Number of physical units aggregated up/downstream" + "name": "Estimated time to next priority shed assuming BESS starts at full charge", + "unit": "min" }, - "fed-by-device-id": { - "datatype": "string", - "name": "Homie device-id of the upstream device feeding this lugs" + "full-charge-total-time-remaining": { + "datatype": "integer", + "name": "Estimated total time assuming BESS starts at full charge", + "unit": "min" }, - "fed-by-device-status": { + "time-to-priority-shed": { + "datatype": "integer", + "name": "Estimated time before the next priority tier is shed", + "unit": "min" + }, + "total-time-remaining": { + "datatype": "integer", + "name": "Estimated total time before all sheddable circuits are shed (off-grid runtime)", + "unit": "min" + } + }, + "type": "energy.ebus.capability.shed-forecast" + }, + "status": { + "name": "status", + "properties": { + "cloud-connection": { "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the upstream device" + "format": "UNKNOWN,UNCONNECTED,CONNECTED", + "name": "Device connected to vendor cloud?" }, - "fed-by-device-type": { - "datatype": "string", - "name": "Homie $type of the upstream device" + "ethernet": { + "datatype": "boolean", + "name": "Is Ethernet network interface operational?" }, - "feeds-device-id": { + "postal-code": { "datatype": "string", - "name": "Homie device-id of the downstream device fed by this lugs" + "name": "Postal (Zip) code" }, - "feeds-device-status": { + "relay": { "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" + "format": "UNKNOWN,OPEN,CLOSED", + "name": "Main relay" }, - "feeds-device-type": { + "time-zone": { "datatype": "string", - "name": "Homie $type of the downstream device" + "name": "Time zone" + }, + "wifi": { + "datatype": "boolean", + "name": "Is Wi-Fi network interface operational?" + }, + "wifi-ssid": { + "datatype": "string", + "name": "SSID to which Wi-Fi network interface is connected" } }, - "type": "energy.ebus.capability.connection" - }, + "type": "energy.ebus.capability.status" + } + }, + "type": "energy.ebus.device.distribution-enclosure", + "version": 1786394128460 + }, + "sim-40t-001-SIM-BESS-40T-001": { + "children": [ + "sim-40t-001-SIM-BESS-40T-001-mid" + ], + "extensions": [], + "homie": "5.0", + "name": "Battery", + "nodes": { "info": { "name": "info", "properties": { - "direction": { - "datatype": "enum", - "format": "UPSTREAM,DOWNSTREAM", - "name": "Lugs feed direction: upstream or downstream" + "firmware-version": { + "datatype": "string", + "name": "Firmware version" + }, + "model": { + "datatype": "string", + "name": "Model" + }, + "nameplate-capacity": { + "datatype": "float", + "name": "Nameplate capacity", + "unit": "kWh" + }, + "part-number": { + "datatype": "string", + "name": "Part number" + }, + "serial-number": { + "datatype": "string", + "name": "Serial number" + }, + "vendor-name": { + "datatype": "string", + "name": "Vendor name" } }, "type": "energy.ebus.capability.info" @@ -4606,42 +4491,69 @@ "datatype": "float", "name": "Active power", "unit": "W" - }, - "current-a": { - "datatype": "float", - "name": "L1 current", - "unit": "A" - }, - "current-b": { - "datatype": "float", - "name": "L2 current", - "unit": "A" - }, - "exported-energy": { + } + }, + "type": "energy.ebus.capability.meter" + }, + "soc": { + "name": "soc", + "properties": { + "soc": { "datatype": "float", - "name": "Exported energy", - "unit": "Wh" + "name": "State of charge", + "unit": "%" }, - "imported-energy": { + "soe": { "datatype": "float", - "name": "Imported energy", - "unit": "Wh" + "name": "State of energy", + "unit": "kWh" } }, - "type": "energy.ebus.capability.meter" + "type": "energy.ebus.capability.soc" + }, + "status": { + "name": "status", + "properties": { + "communication-state": { + "datatype": "enum", + "format": "OK,DEGRADED,LOST,UNKNOWN", + "name": "Communication state" + } + }, + "type": "energy.ebus.capability.status" } }, "parent": "sim-40t-001", "root": "sim-40t-001", - "type": "energy.ebus.device.lugs", - "version": 1786157193484 + "type": "energy.ebus.device.bess", + "version": 1786394128460 }, - "pv": { + "sim-40t-001-SIM-BESS-40T-001-mid": { "children": [], "extensions": [], "homie": "5.0", - "name": "Solar", + "name": "Microgrid Interconnect Device", "nodes": { + "grid": { + "name": "grid", + "properties": { + "grid-forming-entity": { + "datatype": "string", + "name": "Identity of the currently grid-forming entity" + }, + "grid-state": { + "datatype": "enum", + "format": "UP,DOWN,DEGRADED,UNKNOWN", + "name": "Sensed grid condition" + }, + "islanding-state": { + "datatype": "enum", + "format": "ON_GRID,OFF_GRID,UNKNOWN", + "name": "Islanding state of the BESS-integrated grid-forming device" + } + }, + "type": "energy.ebus.capability.grid" + }, "info": { "name": "info", "properties": { @@ -4649,15 +4561,14 @@ "datatype": "string", "name": "Firmware version" }, + "hardware-version": { + "datatype": "string", + "name": "Hardware version" + }, "model": { "datatype": "string", "name": "Model" }, - "nominal-power": { - "datatype": "float", - "name": "Nominal power", - "unit": "W" - }, "serial-number": { "datatype": "string", "name": "Serial number" @@ -4670,96 +4581,49 @@ "type": "energy.ebus.capability.info" } }, - "parent": "sim-40t-001", + "parent": "sim-40t-001-SIM-BESS-40T-001", "root": "sim-40t-001", - "type": "energy.ebus.device.pv", - "version": 1786157193484 + "type": "energy.ebus.device.mid", + "version": 1786394128460 }, - "sim-40t-001": { - "children": [ - "bess", - "770e2de52c33508a8a9ee8878064b46f", - "9429f828509e58d59cb5f0f9f5fee523", - "3d9d86f303cc50d1827be57d4c667e53", - "c058aa11287f50f9b81e5160a0678869", - "f515a0f43b6555b1a196fbb62728c24e", - "3eeb0eb1605e5a7eadac41994b7a096c", - "e0ac90e169e6550ea83fe0b1942f1d0e", - "80a4fada833156ab8112f9d50e252b8f", - "13044bfbcbe5554b8f3dba126bce828f", - "b24483358d29589d8e91d3bf11113269", - "d1ff145887a05b839ede89409c27b398", - "edee3425d50d51ffb022ee999053b2b4", - "c339ec7ce7ff521ca7646f9606baff9f", - "2140a7e253ed54e3bc90a959081df615", - "4d1deb6acb065746b13207b1358f8ca7", - "43a0521737db516f99f14a9964ea4af0", - "e0bc156c85015a609d4132084dfcd6fe", - "afe90839f2725e3e962fb05afa2b6d43", - "4aeb08c46c2c5905a944166413f2f1ef", - "516694a326a35cd88600b3520e8a981a", - "1eeeb748eeaa58edb7e9b7e9dbbdeca7", - "ef972f063451539e8b2ad88e831d87b6", - "af731c49a6785a4cb2ea5549fb8bce7e", - "948dea7788aa5c959b99df0edfabead2", - "be7742043a06554aab2a1e38cc776603", - "4ce8b30e8d3f5c49b9e0ab0c8caf4832", - "249a2f59782e5f1ab317c4632e79afad", - "1bfdc7ecebb0547bbe87a3696cddb0c0", - "6fcb352679ad5bfb8c8a8eab06829b9f", - "b9fa08f1eaaf5d129bd5c78e1d5d937f", - "evse", - "evse-2", - "lugs-upstream", - "lugs-downstream", - "pv" - ], + "sim-40t-001-SIM-EVSE-001": { + "children": [], "extensions": [], "homie": "5.0", - "name": "Span Panel", + "name": "SPAN Drive - Garage", "nodes": { - "breaker": { - "name": "breaker", + "config": { + "name": "config", "properties": { - "rating": { + "max-charge-current": { "datatype": "integer", - "name": "Main breaker rating", + "name": "Commissioned maximum EVSE charge current (installer-configured)", + "unit": "A" + }, + "user-max-charge-current": { + "datatype": "integer", + "name": "User-configured maximum EVSE charge current (ceiling)", + "settable": true, "unit": "A" } }, - "type": "energy.ebus.capability.breaker" - }, - "door": { - "name": "door", - "properties": { - "state": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Door state" - } - }, - "type": "energy.ebus.capability.door" + "type": "energy.ebus.capability.config" }, "info": { "name": "info", "properties": { - "data-model-version": { - "datatype": "string", - "name": "eBus data-model version (parent/child schema discriminator)" - }, "firmware-version": { "datatype": "string", "name": "Firmware version" }, - "hardware-version": { - "datatype": "string", - "name": "Hardware version" - }, "model": { - "datatype": "enum", - "format": "MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48", + "datatype": "string", "name": "Model" }, + "part-number": { + "datatype": "string", + "name": "Part number" + }, "serial-number": { "datatype": "string", "name": "Serial number" @@ -4774,211 +4638,347 @@ "meter": { "name": "meter", "properties": { - "voltage-a": { - "datatype": "float", - "name": "L1 voltage", - "unit": "V" - }, - "voltage-b": { + "advertised-current": { "datatype": "float", - "name": "L2 voltage", - "unit": "V" + "name": "Current EVSE is advertising to the EV", + "unit": "A" } }, "type": "energy.ebus.capability.meter" }, - "pcs": { - "name": "pcs", + "status": { + "name": "status", "properties": { - "active": { - "datatype": "boolean", - "name": "PCS system actively controlling one (or more) loads" - }, - "binding-constraint": { + "status": { "datatype": "enum", - "format": "FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN", - "name": "Which constraint class currently sets the import limit" - }, - "enabled": { - "datatype": "boolean", - "name": "PCS system enabled" - }, - "feed-import-limit": { - "datatype": "float", - "name": "Limit of maximum power feeding the distribution enclosure", - "unit": "A" - }, - "feed-import-limit-active": { - "datatype": "boolean", - "name": "Is feed-import-limit currently being enforced?" - }, - "feed-import-limit-enablement": { + "format": "AVAILABLE,PREPARING,CHARGING,UNAVAILABLE", + "name": "Status" + } + }, + "type": "energy.ebus.capability.status" + }, + "switch": { + "name": "switch", + "properties": { + "lock-state": { "datatype": "enum", - "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", - "name": "Enablement status of the feed-import-limit" - }, - "import-limit": { - "datatype": "float", - "name": "The power import limit currently being managed to", + "format": "UNLOCKED,LOCKED", + "name": "Lock state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.evse", + "version": 1786394128460 + }, + "sim-40t-001-SIM-EVSE-002": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "SPAN Drive - Driveway", + "nodes": { + "config": { + "name": "config", + "properties": { + "max-charge-current": { + "datatype": "integer", + "name": "Commissioned maximum EVSE charge current (installer-configured)", "unit": "A" }, - "off-grid-import-limit": { - "datatype": "float", - "name": "Off-Grid limit maximum import power", + "user-max-charge-current": { + "datatype": "integer", + "name": "User-configured maximum EVSE charge current (ceiling)", + "settable": true, "unit": "A" + } + }, + "type": "energy.ebus.capability.config" + }, + "info": { + "name": "info", + "properties": { + "firmware-version": { + "datatype": "string", + "name": "Firmware version" }, - "off-grid-import-limit-active": { - "datatype": "boolean", - "name": "Is off-grid-import-limit currently being enforced?" + "model": { + "datatype": "string", + "name": "Model" }, - "off-grid-import-limit-enablement": { - "datatype": "enum", - "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", - "name": "Enablement status of the off-grid-import-limit" + "part-number": { + "datatype": "string", + "name": "Part number" }, - "operator-import-limit": { + "serial-number": { + "datatype": "string", + "name": "Serial number" + }, + "vendor-name": { + "datatype": "string", + "name": "Vendor name" + } + }, + "type": "energy.ebus.capability.info" + }, + "meter": { + "name": "meter", + "properties": { + "advertised-current": { "datatype": "float", - "name": "Operator-imposed maximum import limit", + "name": "Current EVSE is advertising to the EV", "unit": "A" + } + }, + "type": "energy.ebus.capability.meter" + }, + "status": { + "name": "status", + "properties": { + "status": { + "datatype": "enum", + "format": "AVAILABLE,PREPARING,CHARGING,UNAVAILABLE", + "name": "Status" + } + }, + "type": "energy.ebus.capability.status" + }, + "switch": { + "name": "switch", + "properties": { + "lock-state": { + "datatype": "enum", + "format": "UNLOCKED,LOCKED", + "name": "Lock state" + } + }, + "type": "energy.ebus.capability.switch" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.evse", + "version": 1786394128460 + }, + "sim-40t-001-lugs-dn": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Downstream lugs", + "nodes": { + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated up/downstream" }, - "operator-import-limit-active": { - "datatype": "boolean", - "name": "Is operator-import-limit currently being enforced?" + "fed-by-device-id": { + "datatype": "string", + "name": "Homie device-id of the upstream device feeding this lugs" }, - "operator-import-limit-enablement": { + "fed-by-device-status": { "datatype": "enum", - "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", - "name": "Enablement status of the operator-import-limit" + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the upstream device" }, - "requested-import-limit": { - "datatype": "float", - "name": "Requested limit maximum import power", - "unit": "A" + "fed-by-device-type": { + "datatype": "string", + "name": "Homie $type of the upstream device" }, - "requested-import-limit-active": { - "datatype": "boolean", - "name": "Is requested-import-limit currently being enforced?" + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this lugs" }, - "requested-import-limit-enablement": { + "feeds-device-status": { "datatype": "enum", - "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", - "name": "Enablement status of the requested-import-limit" + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" } }, - "type": "energy.ebus.capability.pcs" + "type": "energy.ebus.capability.connection" }, - "power-flows": { - "name": "power-flows", + "info": { + "name": "info", "properties": { - "battery": { + "direction": { + "datatype": "enum", + "format": "UPSTREAM,DOWNSTREAM", + "name": "Lugs feed direction: upstream or downstream" + } + }, + "type": "energy.ebus.capability.info" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { "datatype": "float", - "name": "Battery/BESS power flow", + "name": "Active power", "unit": "W" }, - "grid": { + "current-a": { "datatype": "float", - "name": "Grid power flow", - "unit": "W" + "name": "L1 current", + "unit": "A" }, - "pv": { + "current-b": { "datatype": "float", - "name": "PV power flow", - "unit": "W" + "name": "L2 current", + "unit": "A" }, - "site": { + "exported-energy": { "datatype": "float", - "name": "Site power flow", - "unit": "W" + "name": "Exported energy", + "unit": "Wh" + }, + "imported-energy": { + "datatype": "float", + "name": "Imported energy", + "unit": "Wh" } }, - "type": "energy.ebus.capability.power-flows" - }, - "shed": { - "name": "shed", + "type": "energy.ebus.capability.meter" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.lugs", + "version": 1786394128460 + }, + "sim-40t-001-lugs-up": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Upstream lugs", + "nodes": { + "connection": { + "name": "connection", "properties": { - "asserted-islanding-state": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated up/downstream" + }, + "fed-by-device-id": { + "datatype": "string", + "name": "Homie device-id of the upstream device feeding this lugs" + }, + "fed-by-device-status": { "datatype": "enum", - "format": "NONE,ON_GRID,OFF_GRID", - "name": "Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)", - "settable": true + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the upstream device" }, - "policy": { - "datatype": "json", - "format": "{\"$id\":\"soc-priority.v1\",\"type\":\"object\",\"required\":[\"algorithm\",\"parameters\"],\"additionalProperties\":false,\"properties\":{\"algorithm\":{\"const\":\"soc-priority.v1\"},\"parameters\":{\"type\":\"object\",\"required\":[\"soc-threshold-shed\",\"soc-threshold-release\"],\"additionalProperties\":false,\"properties\":{\"soc-threshold-shed\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":100,\"description\":\"SoC percent below which SOC_THRESHOLD circuits shed\"},\"soc-threshold-release\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":100,\"description\":\"SoC percent above which shed SOC_THRESHOLD circuits restore\"}}}}}", - "name": "Shed policy (algorithm and parameters)" + "fed-by-device-type": { + "datatype": "string", + "name": "Homie $type of the upstream device" + }, + "feeds-device-id": { + "datatype": "string", + "name": "Homie device-id of the downstream device fed by this lugs" + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "name": "Panel's view of comm health to the downstream device" + }, + "feeds-device-type": { + "datatype": "string", + "name": "Homie $type of the downstream device" } }, - "type": "energy.ebus.capability.shed" + "type": "energy.ebus.capability.connection" }, - "shed-forecast": { - "name": "shed-forecast", + "info": { + "name": "info", "properties": { - "confidence": { + "direction": { "datatype": "enum", - "format": "LOW,MEDIUM,HIGH", - "name": "Confidence of the shed-forecast estimate" + "format": "UPSTREAM,DOWNSTREAM", + "name": "Lugs feed direction: upstream or downstream" + } + }, + "type": "energy.ebus.capability.info" + }, + "meter": { + "name": "meter", + "properties": { + "active-power": { + "datatype": "float", + "name": "Active power", + "unit": "W" }, - "full-charge-time-to-priority-shed": { - "datatype": "integer", - "name": "Estimated time to next priority shed assuming BESS starts at full charge", - "unit": "min" + "current-a": { + "datatype": "float", + "name": "L1 current", + "unit": "A" }, - "full-charge-total-time-remaining": { - "datatype": "integer", - "name": "Estimated total time assuming BESS starts at full charge", - "unit": "min" + "current-b": { + "datatype": "float", + "name": "L2 current", + "unit": "A" }, - "time-to-priority-shed": { - "datatype": "integer", - "name": "Estimated time before the next priority tier is shed", - "unit": "min" + "exported-energy": { + "datatype": "float", + "name": "Exported energy", + "unit": "Wh" }, - "total-time-remaining": { - "datatype": "integer", - "name": "Estimated total time before all sheddable circuits are shed (off-grid runtime)", - "unit": "min" + "imported-energy": { + "datatype": "float", + "name": "Imported energy", + "unit": "Wh" } }, - "type": "energy.ebus.capability.shed-forecast" - }, - "status": { - "name": "status", + "type": "energy.ebus.capability.meter" + } + }, + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.lugs", + "version": 1786394128460 + }, + "sim-40t-001-pv-1": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Solar", + "nodes": { + "info": { + "name": "info", "properties": { - "cloud-connection": { - "datatype": "enum", - "format": "UNKNOWN,UNCONNECTED,CONNECTED", - "name": "Device connected to vendor cloud?" - }, - "ethernet": { - "datatype": "boolean", - "name": "Is Ethernet network interface operational?" + "firmware-version": { + "datatype": "string", + "name": "Firmware version" }, - "postal-code": { + "model": { "datatype": "string", - "name": "Postal (Zip) code" + "name": "Model" }, - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Main relay" + "nominal-power": { + "datatype": "float", + "name": "Nominal power", + "unit": "W" }, - "time-zone": { + "serial-number": { "datatype": "string", - "name": "Time zone" - }, - "wifi": { - "datatype": "boolean", - "name": "Is Wi-Fi network interface operational?" + "name": "Serial number" }, - "wifi-ssid": { + "vendor-name": { "datatype": "string", - "name": "SSID to which Wi-Fi network interface is connected" + "name": "Vendor name" } }, - "type": "energy.ebus.capability.status" + "type": "energy.ebus.capability.info" } }, - "type": "energy.ebus.device.distribution-enclosure", - "version": 1786157193484 + "parent": "sim-40t-001", + "root": "sim-40t-001", + "type": "energy.ebus.device.pv", + "version": 1786394128460 } } diff --git a/packages/schema-1/spec/fixtures/simulator_wire.json b/packages/schema-1/spec/fixtures/simulator_wire.json index 04780fc..7f84002 100644 --- a/packages/schema-1/spec/fixtures/simulator_wire.json +++ b/packages/schema-1/spec/fixtures/simulator_wire.json @@ -1,14 +1,14 @@ { "13044bfbcbe5554b8f3dba126bce828f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193480, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Island)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128456, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Island)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Kitchen Outlets (Island)", "info/spaces": "10", "load-shed/priority": "NEVER", - "meter/active-power": "-266.9639394044684", - "meter/current": "2.2246994950372367", + "meter/active-power": "-275.66944406952086", + "meter/current": "2.297245367246007", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -18,11 +18,11 @@ "switch/relay-requester": "NONE" }, "1bfdc7ecebb0547bbe87a3696cddb0c0": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193483, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128459, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "50", - "connection/feeds-device-id": "evse-2", + "connection/feeds-device-id": "sim-40t-001-SIM-EVSE-002", "connection/feeds-device-status": "OK", "connection/feeds-device-type": "energy.ebus.device.evse", "info/name": "SPAN Drive - Driveway", @@ -39,15 +39,15 @@ "switch/relay-requester": "NONE" }, "1eeeb748eeaa58edb7e9b7e9dbbdeca7": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193482, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Smoke Detectors\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Smoke Detectors\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Smoke Detectors", "info/spaces": "40", "load-shed/priority": "NEVER", - "meter/active-power": "-4.505989697917756", - "meter/current": "0.037549914149314634", + "meter/active-power": "-4.981953072618312", + "meter/current": "0.0415162756051526", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -57,15 +57,15 @@ "switch/relay-requester": "NONE" }, "2140a7e253ed54e3bc90a959081df615": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193481, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Refrigerator\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128457, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Refrigerator\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Refrigerator", "info/spaces": "15", "load-shed/priority": "NEVER", - "meter/active-power": "-104.5135692126635", - "meter/current": "0.8709464101055292", + "meter/active-power": "-129.03177982477877", + "meter/current": "1.0752648318731564", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "false", @@ -75,11 +75,11 @@ "switch/relay-requester": "NONE" }, "249a2f59782e5f1ab317c4632e79afad": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193483, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128459, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "50", - "connection/feeds-device-id": "evse", + "connection/feeds-device-id": "sim-40t-001-SIM-EVSE-001", "connection/feeds-device-status": "OK", "connection/feeds-device-type": "energy.ebus.device.evse", "info/name": "SPAN Drive - Garage", @@ -96,15 +96,15 @@ "switch/relay-requester": "NONE" }, "3d9d86f303cc50d1827be57d4c667e53": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193479, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128455, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Bedroom Lights", "info/spaces": "4", "load-shed/priority": "NEVER", - "meter/active-power": "-77.71804751120726", - "meter/current": "0.6476503959267271", + "meter/active-power": "-7.762605641998834", + "meter/current": "0.06468838034999029", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -114,15 +114,15 @@ "switch/relay-requester": "NONE" }, "3eeb0eb1605e5a7eadac41994b7a096c": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193479, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128455, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Master Bedroom Outlets", "info/spaces": "7", "load-shed/priority": "NEVER", - "meter/active-power": "-146.27483088563423", - "meter/current": "1.218956924046952", + "meter/active-power": "-138.17131187371243", + "meter/current": "1.1514275989476035", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -132,7 +132,7 @@ "switch/relay-requester": "NONE" }, "43a0521737db516f99f14a9964ea4af0": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193481, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Washing Machine\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128457, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Washing Machine\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", @@ -150,7 +150,7 @@ "switch/relay-requester": "NONE" }, "4aeb08c46c2c5905a944166413f2f1ef": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193482, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garbage Disposal\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garbage Disposal\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", @@ -168,15 +168,15 @@ "switch/relay-requester": "NONE" }, "4ce8b30e8d3f5c49b9e0ab0c8caf4832": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193483, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Water Heater\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128459, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Water Heater\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", "info/name": "Water Heater", "info/spaces": "31,33", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-4500.0", - "meter/current": "18.75", + "meter/active-power": "-2617.818249951638", + "meter/current": "10.907576041465157", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -186,15 +186,15 @@ "switch/relay-requester": "NONE" }, "4d1deb6acb065746b13207b1358f8ca7": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193481, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Dishwasher\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128457, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Dishwasher\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Dishwasher", "info/spaces": "16", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-1800.0", - "meter/current": "15.0", + "meter/active-power": "0.0", + "meter/current": "0.0", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -204,15 +204,15 @@ "switch/relay-requester": "NONE" }, "516694a326a35cd88600b3520e8a981a": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193482, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Pool Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Pool Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Pool Pump", "info/spaces": "39", "load-shed/priority": "OFF_GRID", - "meter/active-power": "0.0", - "meter/current": "0.0", + "meter/active-power": "-779.1262192951103", + "meter/current": "6.49271849412592", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -222,18 +222,18 @@ "switch/relay-requester": "NONE" }, "6fcb352679ad5bfb8c8a8eab06829b9f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193483, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128459, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", - "connection/feeds-device-id": "pv", + "connection/feeds-device-id": "sim-40t-001-pv-1", "connection/feeds-device-status": "OK", "connection/feeds-device-type": "energy.ebus.device.pv", "info/name": "Solar Inverter", "info/spaces": "36,38", "load-shed/priority": "NEVER", - "meter/active-power": "226.07117258699404", - "meter/current": "0.9419632191124752", + "meter/active-power": "9193.069021378888", + "meter/current": "38.30445425574536", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "false", @@ -243,15 +243,15 @@ "switch/relay-requester": "NONE" }, "770e2de52c33508a8a9ee8878064b46f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193479, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128455, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Master Bedroom Lights", "info/spaces": "1", "load-shed/priority": "NEVER", - "meter/active-power": "-37.27458667650669", - "meter/current": "0.3106215556375558", + "meter/active-power": "-3.8000288232900474", + "meter/current": "0.031666906860750396", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -261,15 +261,15 @@ "switch/relay-requester": "NONE" }, "80a4fada833156ab8112f9d50e252b8f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193480, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Counter)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128456, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Counter)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Kitchen Outlets (Counter)", "info/spaces": "9", "load-shed/priority": "NEVER", - "meter/active-power": "-273.94820081512756", - "meter/current": "2.2829016734593965", + "meter/active-power": "-337.1874857933848", + "meter/current": "2.809895714944873", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -279,15 +279,15 @@ "switch/relay-requester": "NONE" }, "9429f828509e58d59cb5f0f9f5fee523": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193479, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128455, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Living Room Lights", "info/spaces": "2", "load-shed/priority": "NEVER", - "meter/active-power": "-53.95340942682396", - "meter/current": "0.449611745223533", + "meter/active-power": "-4.763081384201946", + "meter/current": "0.039692344868349556", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -297,15 +297,15 @@ "switch/relay-requester": "NONE" }, "948dea7788aa5c959b99df0edfabead2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193482, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Heat Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Heat Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", "info/name": "Heat Pump", "info/spaces": "27,29", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-2032.01215918081", - "meter/current": "8.46671732992004", + "meter/active-power": "-1783.3953624572196", + "meter/current": "7.430814010238415", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -315,15 +315,15 @@ "switch/relay-requester": "NONE" }, "af731c49a6785a4cb2ea5549fb8bce7e": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193482, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Main HVAC\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Main HVAC\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", "info/name": "Main HVAC", "info/spaces": "23,25", "load-shed/priority": "NEVER", - "meter/active-power": "-871.8737167912033", - "meter/current": "3.6328071532966804", + "meter/active-power": "-588.2993268423014", + "meter/current": "2.451247195176256", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -333,15 +333,15 @@ "switch/relay-requester": "NONE" }, "afe90839f2725e3e962fb05afa2b6d43": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193481, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Chest Freezer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128457, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Chest Freezer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Chest Freezer", "info/spaces": "19", "load-shed/priority": "NEVER", - "meter/active-power": "-88.32342473910758", - "meter/current": "0.7360285394925632", + "meter/active-power": "-87.99770926735235", + "meter/current": "0.7333142438946029", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "false", @@ -351,15 +351,15 @@ "switch/relay-requester": "NONE" }, "b24483358d29589d8e91d3bf11113269": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193480, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Office Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128456, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Office Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Office Outlets", "info/spaces": "11", "load-shed/priority": "NEVER", - "meter/active-power": "-286.66343376846635", - "meter/current": "2.388861948070553", + "meter/active-power": "-309.29811136402657", + "meter/current": "2.5774842613668882", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -369,15 +369,15 @@ "switch/relay-requester": "NONE" }, "b9fa08f1eaaf5d129bd5c78e1d5d937f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193484, \"type\": \"energy.ebus.device.circuit\", \"name\": \"kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128459, \"type\": \"energy.ebus.device.circuit\", \"name\": \"kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "kitchen Lights", "info/spaces": "3", "load-shed/priority": "NEVER", - "meter/active-power": "-142.54557907902372", - "meter/current": "1.187879825658531", + "meter/active-power": "-129.17671003539766", + "meter/current": "1.0764725836283138", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -387,15 +387,15 @@ "switch/relay-requester": "NONE" }, "be7742043a06554aab2a1e38cc776603": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193483, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Oven/Range\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128459, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Oven/Range\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "40", "info/name": "Electric Oven/Range", "info/spaces": "28,30", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-5000.0", - "meter/current": "20.833333333333332", + "meter/active-power": "0.0", + "meter/current": "0.0", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -404,38 +404,16 @@ "switch/relay-controllable": "true", "switch/relay-requester": "NONE" }, - "bess": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193484, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/model": "SPAN Battery", - "info/nameplate-capacity": "13.5", - "info/part-number": "SPN-BESS-001", - "info/serial-number": "SIM-BESS-40T-001", - "info/vendor-name": "Span", - "meter/active-power": "3500.0", - "soc/soc": "50.0", - "soc/soe": "6.75", - "status/communication-state": "OK" - }, - "bess-mid": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193484, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"bess\", \"extensions\": []}", - "$state": "ready", - "grid/grid-forming-entity": "GRID", - "grid/grid-state": "UP", - "grid/islanding-state": "ON_GRID", - "info/serial-number": "SIM-BESS-40T-001-mid", - "info/vendor-name": "Span" - }, "c058aa11287f50f9b81e5160a0678869": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193479, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bathroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128455, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bathroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Bathroom Lights", "info/spaces": "5", "load-shed/priority": "NEVER", - "meter/active-power": "-30.98175549826103", - "meter/current": "0.2581812958188419", + "meter/active-power": "-3.0169707781369457", + "meter/current": "0.025141423151141214", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -445,15 +423,15 @@ "switch/relay-requester": "NONE" }, "c339ec7ce7ff521ca7646f9606baff9f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193481, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Guest Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128457, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Guest Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Guest Room Outlets", "info/spaces": "14", "load-shed/priority": "NEVER", - "meter/active-power": "-161.6716994120287", - "meter/current": "1.3472641617669057", + "meter/active-power": "-150.19731329810756", + "meter/current": "1.2516442774842296", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -463,15 +441,15 @@ "switch/relay-requester": "NONE" }, "d1ff145887a05b839ede89409c27b398": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193480, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128456, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Garage Outlets", "info/spaces": "12", "load-shed/priority": "NEVER", - "meter/active-power": "-134.02830439940539", - "meter/current": "1.1169025366617116", + "meter/active-power": "-164.6978612658996", + "meter/current": "1.37248217721583", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -481,15 +459,15 @@ "switch/relay-requester": "NONE" }, "e0ac90e169e6550ea83fe0b1942f1d0e": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193480, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128456, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Living Room Outlets", "info/spaces": "8", "load-shed/priority": "NEVER", - "meter/active-power": "-246.81992934282277", - "meter/current": "2.0568327445235233", + "meter/active-power": "-261.26659817397", + "meter/current": "2.17722165144975", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -499,7 +477,7 @@ "switch/relay-requester": "NONE" }, "e0bc156c85015a609d4132084dfcd6fe": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193481, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Microwave\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128457, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Microwave\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", @@ -517,15 +495,15 @@ "switch/relay-requester": "NONE" }, "edee3425d50d51ffb022ee999053b2b4": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193480, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Laundry Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128456, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Laundry Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Laundry Room Outlets", "info/spaces": "13", "load-shed/priority": "NEVER", - "meter/active-power": "-158.846439136271", - "meter/current": "1.3237203261355917", + "meter/active-power": "-163.67264716924112", + "meter/current": "1.3639387264103426", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -535,7 +513,7 @@ "switch/relay-requester": "NONE" }, "ef972f063451539e8b2ad88e831d87b6": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193482, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Dryer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Dryer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", @@ -552,44 +530,16 @@ "switch/relay-controllable": "true", "switch/relay-requester": "NONE" }, - "evse": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193484, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", - "$state": "ready", - "config/max-charge-current": "32", - "config/user-max-charge-current": "32", - "info/firmware-version": "sim/v0.1.0", - "info/model": "SPAN Drive", - "info/part-number": "SPN-DRV-001", - "info/serial-number": "SIM-EVSE-sim-40t-001", - "info/vendor-name": "SPAN", - "meter/advertised-current": "32.0", - "status/status": "AVAILABLE", - "switch/lock-state": "UNLOCKED" - }, - "evse-2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193484, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", - "$state": "ready", - "config/max-charge-current": "32", - "config/user-max-charge-current": "32", - "info/firmware-version": "sim/v0.1.0", - "info/model": "SPAN Drive", - "info/part-number": "SPN-DRV-001", - "info/serial-number": "SIM-EVSE-sim-40t-001-2", - "info/vendor-name": "SPAN", - "meter/advertised-current": "32.0", - "status/status": "AVAILABLE", - "switch/lock-state": "UNLOCKED" - }, "f515a0f43b6555b1a196fbb62728c24e": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193479, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Exterior Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128455, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Exterior Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Exterior Lights", "info/spaces": "6", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-44.35131037211919", - "meter/current": "0.3695942531009932", + "meter/active-power": "0.0", + "meter/current": "0.0", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -598,38 +548,8 @@ "switch/relay-controllable": "true", "switch/relay-requester": "NONE" }, - "lugs-downstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193484, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/direction": "DOWNSTREAM", - "meter/active-power": "17737.199152762874", - "meter/current-a": "63.20291953408804", - "meter/current-b": "88.37492628205247", - "meter/exported-energy": "0.0", - "meter/imported-energy": "0.0" - }, - "lugs-upstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193484, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", - "$state": "ready", - "connection/fed-by-device-id": "bess", - "connection/fed-by-device-status": "OK", - "connection/fed-by-device-type": "energy.ebus.device.bess", - "info/direction": "UPSTREAM", - "meter/active-power": "17737.199152762874", - "meter/current-a": "63.20291953408804", - "meter/current-b": "88.37492628205247", - "meter/exported-energy": "0.0", - "meter/imported-energy": "0.0" - }, - "pv": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193484, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/model": "IQ8PLUS-72-2-US", - "info/nominal-power": "10000.0", - "info/vendor-name": "Enphase" - }, "sim-40t-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786157193484, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Span Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"770e2de52c33508a8a9ee8878064b46f\", \"9429f828509e58d59cb5f0f9f5fee523\", \"3d9d86f303cc50d1827be57d4c667e53\", \"c058aa11287f50f9b81e5160a0678869\", \"f515a0f43b6555b1a196fbb62728c24e\", \"3eeb0eb1605e5a7eadac41994b7a096c\", \"e0ac90e169e6550ea83fe0b1942f1d0e\", \"80a4fada833156ab8112f9d50e252b8f\", \"13044bfbcbe5554b8f3dba126bce828f\", \"b24483358d29589d8e91d3bf11113269\", \"d1ff145887a05b839ede89409c27b398\", \"edee3425d50d51ffb022ee999053b2b4\", \"c339ec7ce7ff521ca7646f9606baff9f\", \"2140a7e253ed54e3bc90a959081df615\", \"4d1deb6acb065746b13207b1358f8ca7\", \"43a0521737db516f99f14a9964ea4af0\", \"e0bc156c85015a609d4132084dfcd6fe\", \"afe90839f2725e3e962fb05afa2b6d43\", \"4aeb08c46c2c5905a944166413f2f1ef\", \"516694a326a35cd88600b3520e8a981a\", \"1eeeb748eeaa58edb7e9b7e9dbbdeca7\", \"ef972f063451539e8b2ad88e831d87b6\", \"af731c49a6785a4cb2ea5549fb8bce7e\", \"948dea7788aa5c959b99df0edfabead2\", \"be7742043a06554aab2a1e38cc776603\", \"4ce8b30e8d3f5c49b9e0ab0c8caf4832\", \"249a2f59782e5f1ab317c4632e79afad\", \"1bfdc7ecebb0547bbe87a3696cddb0c0\", \"6fcb352679ad5bfb8c8a8eab06829b9f\", \"b9fa08f1eaaf5d129bd5c78e1d5d937f\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128460, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Span Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"sim-40t-001-SIM-BESS-40T-001\", \"770e2de52c33508a8a9ee8878064b46f\", \"9429f828509e58d59cb5f0f9f5fee523\", \"3d9d86f303cc50d1827be57d4c667e53\", \"c058aa11287f50f9b81e5160a0678869\", \"f515a0f43b6555b1a196fbb62728c24e\", \"3eeb0eb1605e5a7eadac41994b7a096c\", \"e0ac90e169e6550ea83fe0b1942f1d0e\", \"80a4fada833156ab8112f9d50e252b8f\", \"13044bfbcbe5554b8f3dba126bce828f\", \"b24483358d29589d8e91d3bf11113269\", \"d1ff145887a05b839ede89409c27b398\", \"edee3425d50d51ffb022ee999053b2b4\", \"c339ec7ce7ff521ca7646f9606baff9f\", \"2140a7e253ed54e3bc90a959081df615\", \"4d1deb6acb065746b13207b1358f8ca7\", \"43a0521737db516f99f14a9964ea4af0\", \"e0bc156c85015a609d4132084dfcd6fe\", \"afe90839f2725e3e962fb05afa2b6d43\", \"4aeb08c46c2c5905a944166413f2f1ef\", \"516694a326a35cd88600b3520e8a981a\", \"1eeeb748eeaa58edb7e9b7e9dbbdeca7\", \"ef972f063451539e8b2ad88e831d87b6\", \"af731c49a6785a4cb2ea5549fb8bce7e\", \"948dea7788aa5c959b99df0edfabead2\", \"be7742043a06554aab2a1e38cc776603\", \"4ce8b30e8d3f5c49b9e0ab0c8caf4832\", \"249a2f59782e5f1ab317c4632e79afad\", \"1bfdc7ecebb0547bbe87a3696cddb0c0\", \"6fcb352679ad5bfb8c8a8eab06829b9f\", \"b9fa08f1eaaf5d129bd5c78e1d5d937f\", \"sim-40t-001-SIM-EVSE-001\", \"sim-40t-001-SIM-EVSE-002\", \"sim-40t-001-lugs-up\", \"sim-40t-001-lugs-dn\", \"sim-40t-001-pv-1\"], \"extensions\": []}", "$state": "ready", "breaker/rating": "200", "door/state": "CLOSED", @@ -657,10 +577,10 @@ "pcs/requested-import-limit": "0.0", "pcs/requested-import-limit-active": "false", "pcs/requested-import-limit-enablement": "UNCONFIGURED", - "power-flows/battery": "3500.0", - "power-flows/grid": "14237.199152762874", - "power-flows/pv": "226.07117258699404", - "power-flows/site": "17963.27032534987", + "power-flows/battery": "246.26174900301885", + "power-flows/grid": "0.0", + "power-flows/pv": "9193.069021378888", + "power-flows/site": "9439.330770381906", "shed-forecast/confidence": "HIGH", "shed-forecast/full-charge-time-to-priority-shed": "3038", "shed-forecast/full-charge-total-time-remaining": "4320", @@ -674,5 +594,85 @@ "status/relay": "CLOSED", "status/time-zone": "America/Los_Angeles", "status/wifi": "true" + }, + "sim-40t-001-SIM-BESS-40T-001": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128460, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"sim-40t-001-SIM-BESS-40T-001-mid\"], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/model": "SPAN Battery", + "info/nameplate-capacity": "13.5", + "info/part-number": "SPN-BESS-001", + "info/serial-number": "SIM-BESS-40T-001", + "info/vendor-name": "Span", + "meter/active-power": "246.26174900301885", + "soc/soc": "50.0", + "soc/soe": "6.75", + "status/communication-state": "OK" + }, + "sim-40t-001-SIM-BESS-40T-001-mid": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128460, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001-SIM-BESS-40T-001\", \"extensions\": []}", + "$state": "ready", + "grid/grid-forming-entity": "GRID", + "grid/grid-state": "UP", + "grid/islanding-state": "ON_GRID", + "info/serial-number": "SIM-BESS-40T-001-mid", + "info/vendor-name": "Span" + }, + "sim-40t-001-SIM-EVSE-001": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128460, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "sim/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-001", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "AVAILABLE", + "switch/lock-state": "UNLOCKED" + }, + "sim-40t-001-SIM-EVSE-002": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128460, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "sim/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-002", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "AVAILABLE", + "switch/lock-state": "UNLOCKED" + }, + "sim-40t-001-lugs-dn": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128460, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/direction": "DOWNSTREAM", + "meter/active-power": "0", + "meter/current-a": "0.0", + "meter/current-b": "0.0", + "meter/exported-energy": "0", + "meter/imported-energy": "0" + }, + "sim-40t-001-lugs-up": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128460, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "connection/fed-by-device-id": "sim-40t-001-SIM-BESS-40T-001", + "connection/fed-by-device-status": "OK", + "connection/fed-by-device-type": "energy.ebus.device.bess", + "info/direction": "UPSTREAM", + "meter/active-power": "246.26174900301885", + "meter/current-a": "76.43141628782878", + "meter/current-b": "78.8385819768445", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0" + }, + "sim-40t-001-pv-1": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786394128460, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/model": "IQ8PLUS-72-2-US", + "info/nominal-power": "10000.0", + "info/vendor-name": "Enphase" } } diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json index b8b0188..4d7c565 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -14,7 +14,7 @@ "repo": "https://github.com/SpanPanel/panelbench", "ref": "feat/adopt-upstream-emitter", "role": "publisher", - "commit": "43ec5b0ab296d3b6d0aacbe47318a25d63d20f05", + "commit": "38fb6343cc8afc1cecedb720a4418321a1889501", "synced_commit": "4254526b0a8c11cab0d40fd700b1fc295c0479c6", "firmware_range": "r202633+", "fixtures": { diff --git a/tests/test_schema_migration_delta.py b/tests/test_schema_migration_delta.py index 04e0044..941e3a4 100644 --- a/tests/test_schema_migration_delta.py +++ b/tests/test_schema_migration_delta.py @@ -134,7 +134,7 @@ "more conservative than the data requires — reconstruction is an open item" ), "panel.current_run_config": ( - "reads UNKNOWN on v1.0 where flat answered; no v1.0 source identified yet. " "Severity 2 in the delta document" + "reads UNKNOWN on v1.0 where flat answered; no v1.0 source identified yet. Severity 2 in the delta document" ), } """Fields that survive the migration as entities but stop carrying an answer. @@ -276,7 +276,11 @@ def test_both_captures_describe_the_same_logical_panel(flat: Any, parent_child: migration delta and two simulators being configured differently.""" assert flat.serial_number == parent_child.serial_number == _SERIAL assert len(flat.circuits) == len(parent_child.circuits) - assert set(flat.evse) == set(parent_child.evse) + # Count, not keys. The EVSE keys legitimately differ across the migration — + # that is a delta, not a configuration difference, and asserting sameness here + # would put a real finding in the premise where it reads as a broken fixture. + # `test_evse_identity_does_not_survive_the_migration` holds it instead. + assert len(flat.evse) == len(parent_child.evse) def test_every_circuit_keeps_its_identity_across_the_migration(flat: Any, parent_child: Any) -> None: @@ -287,10 +291,53 @@ def test_every_circuit_keeps_its_identity_across_the_migration(flat: Any, parent long-term statistics stay continuous. A UUID that moved would orphan a circuit's entire history — 32 circuits' worth, silently. """ - assert set(flat.circuits) == set(parent_child.circuits), ( - "circuit identities diverge across the migration; every non-matching circuit " "loses its recorder history" + assert set(flat.circuits) == set( + parent_child.circuits + ), "circuit identities diverge across the migration; every non-matching circuit loses its recorder history" + + +def test_evse_identity_does_not_survive_the_migration(flat: Any, parent_child: Any) -> None: + """The circuit test's answer, inverted — and only visible once the ids were right. + + Flat keys an EVSE by its node name (`evse`, `evse-2`). v1.0 keys it by the + proxied device id the migration guide specifies, `-`. + Those are disjoint, so nothing carries over. + + **This was invisible until 2026-08-10.** The v1.0 producer published bare + `evse` / `evse-2` — flat-shaped ids that no panel publishes — inherited from an + example script. Both sides matched, the premise check passed, and EVSE identity + looked as safe as circuit identity. Correcting the producer's ids + (panelbench `38fb634`) made the two sides disagree, which is the true state. + + **What this does and does not establish.** It establishes that the snapshot key + changes, and the snapshot key is what an `evse` entity's identity is built from + here. It does *not* establish that a user loses EVSE history: the integration + still pins `2.6.4` and is not on this adapter, so how it derives an EVSE + `unique_id` cannot be read from this repository. Nor is the flat side attested — + the frozen simulator supplies it and the one live panel available has no Drives, + so real flat firmware's EVSE identity is unverified. + + So this is a *finding pending firmware confirmation*, not a settled break. It is + asserted rather than left to a document because the failure mode it guards + against is the one that already happened: a producer detail quietly making the + comparison come out reassuring. + + Circuits, by contrast, are attested and identical — see + `test_every_circuit_keeps_its_identity_across_the_migration`. + """ + assert len(flat.evse) == len(parent_child.evse) > 0, "the two captures model different EVSE counts" + + assert not (set(flat.evse) & set(parent_child.evse)), ( + "EVSE identities now overlap across the migration. If the producer's ids were " + "corrected toward - this should be disjoint; an overlap " + "means something reintroduced flat-shaped ids on the v1.0 side, which is what " + "hid this break until 2026-08-10." ) + assert all( + key.startswith(f"{_SERIAL}-") for key in parent_child.evse + ), f"v1.0 EVSE keys should be - with this panel as proxier, got {sorted(parent_child.evse)}" + def test_no_circuit_field_is_orphaned(flat: Any, parent_child: Any) -> None: """Circuits are 96% of the entity surface and the attested part of the flat @@ -324,9 +371,9 @@ def test_every_orphan_is_a_decision_someone_made(flat: Any, parent_child: Any) - ), "these fields are populated on flat and absent on v1.0, and nobody decided that:\n " + "\n ".join(unexplained) stale = sorted(set(EXPECTED_ORPHANS) - orphans) - assert not stale, ( - "these are recorded as orphans but no longer are; delete them so the list keeps " f"meaning something: {stale}" - ) + assert ( + not stale + ), f"these are recorded as orphans but no longer are; delete them so the list keeps meaning something: {stale}" def test_every_degraded_field_is_a_known_one(flat: Any, parent_child: Any) -> None: diff --git a/tests/test_schema_one_against_simulator.py b/tests/test_schema_one_against_simulator.py index 7f740c2..a3ea637 100644 --- a/tests/test_schema_one_against_simulator.py +++ b/tests/test_schema_one_against_simulator.py @@ -119,6 +119,16 @@ def test_no_der_declares_a_model_it_never_publishes(adapter: SchemaOneAdapter) - ) +_DER_TYPES = frozenset( + { + "energy.ebus.device.bess", + "energy.ebus.device.pv", + "energy.ebus.device.evse", + } +) +"""The proxied DER classes, which are what the over-declaration check covers.""" + + def test_the_ders_still_declare_two_identity_fields_they_never_publish() -> None: """The rest of §5.2, which adopting the upstream emitter did *not* close. @@ -135,26 +145,39 @@ def test_the_ders_still_declare_two_identity_fields_they_never_publish() -> None Pinned as an exact set so it fails in either direction: a new over-declaration appears, or one of these is finally published and the expectation should shrink. + + **Keyed by device type, not device id.** The ids are `-` + and move with the panel serial and the DER's own serial, so keying on them + would make this fail whenever a config changed — for a reason that has nothing + to do with what it measures. Type is the stable discriminator, and it is what + the mapper itself resolves on. """ with _WIRE.open() as handle: wire = json.load(handle) with (_WIRE.parent / "simulator_tree.json").open() as handle: tree = json.load(handle) - gaps = {} - for device in ("bess", "pv", "evse", "evse-2"): + gaps: dict[str, list[str]] = {} + for device_id, description in tree.items(): + device_type = str(description.get("type") or "") + if device_type not in _DER_TYPES: + continue declared = { f"{node}/{prop}" - for node, body in (tree[device].get("nodes") or {}).items() + for node, body in (description.get("nodes") or {}).items() for prop in (body.get("properties") or {}) } - published = {key for key in wire[device] if not key.startswith("$")} + published = {key for key in wire[device_id] if not key.startswith("$")} if absent := sorted(declared - published): - gaps[device] = absent + already = gaps.setdefault(device_type, absent) + assert already == absent, ( + f"two {device_type} devices disagree on which declarations go unpublished " + f"({already} vs {absent}); collapsing by type would hide one of them" + ) assert gaps == { - "bess": ["info/firmware-version"], - "pv": ["info/firmware-version", "info/serial-number"], + "energy.ebus.device.bess": ["info/firmware-version"], + "energy.ebus.device.pv": ["info/firmware-version", "info/serial-number"], }, f"the declared-but-unpublished set moved: {gaps}" From 05ff100841ac67ecf2fddbfa84bd3f75edd8a306 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:30:06 -0700 Subject: [PATCH 055/115] test(schema_1): re-vendor after the EVSE serial revert panelbench c83c56c restored `evse/serial-number` to `SIM-EVSE-`, the value the frozen flat simulator publishes. Measured after re-vendoring: v1.0 serials: ['SIM-EVSE-sim-40t-001', 'SIM-EVSE-sim-40t-001-2'] flat serials: ['SIM-EVSE-sim-40t-001', 'SIM-EVSE-sim-40t-001-2'] Identical, which matters because `info/serial-number` is the only identifier that can recognise the same physical Drive on both sides of the migration -- the proxy model designates it precisely because a proxied device id is not stable. The two captures now model the same two chargers again rather than four. peer.commit 38fb634 -> c83c56c. 592 passed. --- .../spec/fixtures/simulator_tree.json | 84 ++++---- .../spec/fixtures/simulator_wire.json | 200 +++++++++--------- .../span_panel_api_schema_1/spec_lock.json | 2 +- 3 files changed, 143 insertions(+), 143 deletions(-) diff --git a/packages/schema-1/spec/fixtures/simulator_tree.json b/packages/schema-1/spec/fixtures/simulator_tree.json index 5313ebf..509f8d8 100644 --- a/packages/schema-1/spec/fixtures/simulator_tree.json +++ b/packages/schema-1/spec/fixtures/simulator_tree.json @@ -135,7 +135,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128456 + "version": 1786400923432 }, "1bfdc7ecebb0547bbe87a3696cddb0c0": { "children": [], @@ -273,7 +273,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128459 + "version": 1786400923435 }, "1eeeb748eeaa58edb7e9b7e9dbbdeca7": { "children": [], @@ -411,7 +411,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128458 + "version": 1786400923434 }, "2140a7e253ed54e3bc90a959081df615": { "children": [], @@ -549,7 +549,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128457 + "version": 1786400923433 }, "249a2f59782e5f1ab317c4632e79afad": { "children": [], @@ -687,7 +687,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128459 + "version": 1786400923435 }, "3d9d86f303cc50d1827be57d4c667e53": { "children": [], @@ -825,7 +825,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128455 + "version": 1786400923431 }, "3eeb0eb1605e5a7eadac41994b7a096c": { "children": [], @@ -963,7 +963,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128455 + "version": 1786400923431 }, "43a0521737db516f99f14a9964ea4af0": { "children": [], @@ -1101,7 +1101,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128457 + "version": 1786400923433 }, "4aeb08c46c2c5905a944166413f2f1ef": { "children": [], @@ -1239,7 +1239,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128458 + "version": 1786400923434 }, "4ce8b30e8d3f5c49b9e0ab0c8caf4832": { "children": [], @@ -1377,7 +1377,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128459 + "version": 1786400923435 }, "4d1deb6acb065746b13207b1358f8ca7": { "children": [], @@ -1515,7 +1515,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128457 + "version": 1786400923433 }, "516694a326a35cd88600b3520e8a981a": { "children": [], @@ -1653,7 +1653,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128458 + "version": 1786400923434 }, "6fcb352679ad5bfb8c8a8eab06829b9f": { "children": [], @@ -1791,7 +1791,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128459 + "version": 1786400923435 }, "770e2de52c33508a8a9ee8878064b46f": { "children": [], @@ -1929,7 +1929,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128455 + "version": 1786400923430 }, "80a4fada833156ab8112f9d50e252b8f": { "children": [], @@ -2067,7 +2067,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128456 + "version": 1786400923432 }, "9429f828509e58d59cb5f0f9f5fee523": { "children": [], @@ -2205,7 +2205,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128455 + "version": 1786400923431 }, "948dea7788aa5c959b99df0edfabead2": { "children": [], @@ -2343,7 +2343,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128458 + "version": 1786400923435 }, "af731c49a6785a4cb2ea5549fb8bce7e": { "children": [], @@ -2481,7 +2481,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128458 + "version": 1786400923434 }, "afe90839f2725e3e962fb05afa2b6d43": { "children": [], @@ -2619,7 +2619,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128457 + "version": 1786400923433 }, "b24483358d29589d8e91d3bf11113269": { "children": [], @@ -2757,7 +2757,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128456 + "version": 1786400923432 }, "b9fa08f1eaaf5d129bd5c78e1d5d937f": { "children": [], @@ -2895,7 +2895,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128459 + "version": 1786400923436 }, "be7742043a06554aab2a1e38cc776603": { "children": [], @@ -3033,7 +3033,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128459 + "version": 1786400923435 }, "c058aa11287f50f9b81e5160a0678869": { "children": [], @@ -3171,7 +3171,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128455 + "version": 1786400923431 }, "c339ec7ce7ff521ca7646f9606baff9f": { "children": [], @@ -3309,7 +3309,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128457 + "version": 1786400923433 }, "d1ff145887a05b839ede89409c27b398": { "children": [], @@ -3447,7 +3447,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128456 + "version": 1786400923432 }, "e0ac90e169e6550ea83fe0b1942f1d0e": { "children": [], @@ -3585,7 +3585,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128456 + "version": 1786400923432 }, "e0bc156c85015a609d4132084dfcd6fe": { "children": [], @@ -3723,7 +3723,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128457 + "version": 1786400923433 }, "edee3425d50d51ffb022ee999053b2b4": { "children": [], @@ -3861,7 +3861,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128456 + "version": 1786400923432 }, "ef972f063451539e8b2ad88e831d87b6": { "children": [], @@ -3999,7 +3999,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128458 + "version": 1786400923434 }, "f515a0f43b6555b1a196fbb62728c24e": { "children": [], @@ -4137,7 +4137,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786394128455 + "version": 1786400923431 }, "sim-40t-001": { "children": [ @@ -4172,8 +4172,8 @@ "1bfdc7ecebb0547bbe87a3696cddb0c0", "6fcb352679ad5bfb8c8a8eab06829b9f", "b9fa08f1eaaf5d129bd5c78e1d5d937f", - "sim-40t-001-SIM-EVSE-001", - "sim-40t-001-SIM-EVSE-002", + "sim-40t-001-SIM-EVSE-sim-40t-001", + "sim-40t-001-SIM-EVSE-sim-40t-001-2", "sim-40t-001-lugs-up", "sim-40t-001-lugs-dn", "sim-40t-001-pv-1" @@ -4443,7 +4443,7 @@ } }, "type": "energy.ebus.device.distribution-enclosure", - "version": 1786394128460 + "version": 1786400923436 }, "sim-40t-001-SIM-BESS-40T-001": { "children": [ @@ -4526,7 +4526,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.bess", - "version": 1786394128460 + "version": 1786400923436 }, "sim-40t-001-SIM-BESS-40T-001-mid": { "children": [], @@ -4584,9 +4584,9 @@ "parent": "sim-40t-001-SIM-BESS-40T-001", "root": "sim-40t-001", "type": "energy.ebus.device.mid", - "version": 1786394128460 + "version": 1786400923436 }, - "sim-40t-001-SIM-EVSE-001": { + "sim-40t-001-SIM-EVSE-sim-40t-001": { "children": [], "extensions": [], "homie": "5.0", @@ -4672,9 +4672,9 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.evse", - "version": 1786394128460 + "version": 1786400923436 }, - "sim-40t-001-SIM-EVSE-002": { + "sim-40t-001-SIM-EVSE-sim-40t-001-2": { "children": [], "extensions": [], "homie": "5.0", @@ -4760,7 +4760,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.evse", - "version": 1786394128460 + "version": 1786400923436 }, "sim-40t-001-lugs-dn": { "children": [], @@ -4850,7 +4850,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.lugs", - "version": 1786394128460 + "version": 1786400923436 }, "sim-40t-001-lugs-up": { "children": [], @@ -4940,7 +4940,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.lugs", - "version": 1786394128460 + "version": 1786400923436 }, "sim-40t-001-pv-1": { "children": [], @@ -4979,6 +4979,6 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.pv", - "version": 1786394128460 + "version": 1786400923436 } } diff --git a/packages/schema-1/spec/fixtures/simulator_wire.json b/packages/schema-1/spec/fixtures/simulator_wire.json index 7f84002..5755b6f 100644 --- a/packages/schema-1/spec/fixtures/simulator_wire.json +++ b/packages/schema-1/spec/fixtures/simulator_wire.json @@ -1,14 +1,14 @@ { "13044bfbcbe5554b8f3dba126bce828f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128456, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Island)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923432, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Island)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Kitchen Outlets (Island)", "info/spaces": "10", "load-shed/priority": "NEVER", - "meter/active-power": "-275.66944406952086", - "meter/current": "2.297245367246007", + "meter/active-power": "-344.1586425497289", + "meter/current": "2.867988687914407", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -18,11 +18,11 @@ "switch/relay-requester": "NONE" }, "1bfdc7ecebb0547bbe87a3696cddb0c0": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128459, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923435, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "50", - "connection/feeds-device-id": "sim-40t-001-SIM-EVSE-002", + "connection/feeds-device-id": "sim-40t-001-SIM-EVSE-sim-40t-001-2", "connection/feeds-device-status": "OK", "connection/feeds-device-type": "energy.ebus.device.evse", "info/name": "SPAN Drive - Driveway", @@ -39,15 +39,15 @@ "switch/relay-requester": "NONE" }, "1eeeb748eeaa58edb7e9b7e9dbbdeca7": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Smoke Detectors\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923434, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Smoke Detectors\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Smoke Detectors", "info/spaces": "40", "load-shed/priority": "NEVER", - "meter/active-power": "-4.981953072618312", - "meter/current": "0.0415162756051526", + "meter/active-power": "-4.66117531222462", + "meter/current": "0.03884312760187183", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -57,15 +57,15 @@ "switch/relay-requester": "NONE" }, "2140a7e253ed54e3bc90a959081df615": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128457, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Refrigerator\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923433, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Refrigerator\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Refrigerator", "info/spaces": "15", "load-shed/priority": "NEVER", - "meter/active-power": "-129.03177982477877", - "meter/current": "1.0752648318731564", + "meter/active-power": "-103.94731923028247", + "meter/current": "0.866227660252354", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "false", @@ -75,11 +75,11 @@ "switch/relay-requester": "NONE" }, "249a2f59782e5f1ab317c4632e79afad": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128459, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923435, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "50", - "connection/feeds-device-id": "sim-40t-001-SIM-EVSE-001", + "connection/feeds-device-id": "sim-40t-001-SIM-EVSE-sim-40t-001", "connection/feeds-device-status": "OK", "connection/feeds-device-type": "energy.ebus.device.evse", "info/name": "SPAN Drive - Garage", @@ -96,15 +96,15 @@ "switch/relay-requester": "NONE" }, "3d9d86f303cc50d1827be57d4c667e53": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128455, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923431, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Bedroom Lights", "info/spaces": "4", "load-shed/priority": "NEVER", - "meter/active-power": "-7.762605641998834", - "meter/current": "0.06468838034999029", + "meter/active-power": "-7.300265427089079", + "meter/current": "0.060835545225742325", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -114,15 +114,15 @@ "switch/relay-requester": "NONE" }, "3eeb0eb1605e5a7eadac41994b7a096c": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128455, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923431, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Master Bedroom Outlets", "info/spaces": "7", "load-shed/priority": "NEVER", - "meter/active-power": "-138.17131187371243", - "meter/current": "1.1514275989476035", + "meter/active-power": "-166.12973595258822", + "meter/current": "1.3844144662715685", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -132,15 +132,15 @@ "switch/relay-requester": "NONE" }, "43a0521737db516f99f14a9964ea4af0": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128457, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Washing Machine\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923433, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Washing Machine\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Washing Machine", "info/spaces": "17", "load-shed/priority": "OFF_GRID", - "meter/active-power": "0.0", - "meter/current": "0.0", + "meter/active-power": "-1188.0673538133055", + "meter/current": "9.900561281777547", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -150,7 +150,7 @@ "switch/relay-requester": "NONE" }, "4aeb08c46c2c5905a944166413f2f1ef": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garbage Disposal\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923434, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garbage Disposal\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", @@ -168,15 +168,15 @@ "switch/relay-requester": "NONE" }, "4ce8b30e8d3f5c49b9e0ab0c8caf4832": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128459, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Water Heater\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923435, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Water Heater\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", "info/name": "Water Heater", "info/spaces": "31,33", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-2617.818249951638", - "meter/current": "10.907576041465157", + "meter/active-power": "-2592.5524192875587", + "meter/current": "10.802301747031494", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -186,7 +186,7 @@ "switch/relay-requester": "NONE" }, "4d1deb6acb065746b13207b1358f8ca7": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128457, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Dishwasher\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923433, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Dishwasher\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", @@ -204,15 +204,15 @@ "switch/relay-requester": "NONE" }, "516694a326a35cd88600b3520e8a981a": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Pool Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923434, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Pool Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Pool Pump", "info/spaces": "39", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-779.1262192951103", - "meter/current": "6.49271849412592", + "meter/active-power": "-690.8276310218992", + "meter/current": "5.756896925182493", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -222,7 +222,7 @@ "switch/relay-requester": "NONE" }, "6fcb352679ad5bfb8c8a8eab06829b9f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128459, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923435, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", @@ -232,8 +232,8 @@ "info/name": "Solar Inverter", "info/spaces": "36,38", "load-shed/priority": "NEVER", - "meter/active-power": "9193.069021378888", - "meter/current": "38.30445425574536", + "meter/active-power": "7327.47708818823", + "meter/current": "30.531154534117622", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "false", @@ -243,15 +243,15 @@ "switch/relay-requester": "NONE" }, "770e2de52c33508a8a9ee8878064b46f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128455, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923430, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Master Bedroom Lights", "info/spaces": "1", "load-shed/priority": "NEVER", - "meter/active-power": "-3.8000288232900474", - "meter/current": "0.031666906860750396", + "meter/active-power": "-4.005376786632718", + "meter/current": "0.03337813988860598", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -261,15 +261,15 @@ "switch/relay-requester": "NONE" }, "80a4fada833156ab8112f9d50e252b8f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128456, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Counter)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923432, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Counter)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Kitchen Outlets (Counter)", "info/spaces": "9", "load-shed/priority": "NEVER", - "meter/active-power": "-337.1874857933848", - "meter/current": "2.809895714944873", + "meter/active-power": "-280.8901198038773", + "meter/current": "2.3407509983656443", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -279,15 +279,15 @@ "switch/relay-requester": "NONE" }, "9429f828509e58d59cb5f0f9f5fee523": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128455, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923431, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Living Room Lights", "info/spaces": "2", "load-shed/priority": "NEVER", - "meter/active-power": "-4.763081384201946", - "meter/current": "0.039692344868349556", + "meter/active-power": "-5.096271387725672", + "meter/current": "0.04246892823104727", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -297,15 +297,15 @@ "switch/relay-requester": "NONE" }, "948dea7788aa5c959b99df0edfabead2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Heat Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923435, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Heat Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", "info/name": "Heat Pump", "info/spaces": "27,29", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-1783.3953624572196", - "meter/current": "7.430814010238415", + "meter/active-power": "-1929.021910987512", + "meter/current": "8.0375912957813", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -315,15 +315,15 @@ "switch/relay-requester": "NONE" }, "af731c49a6785a4cb2ea5549fb8bce7e": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Main HVAC\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923434, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Main HVAC\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", "info/name": "Main HVAC", "info/spaces": "23,25", "load-shed/priority": "NEVER", - "meter/active-power": "-588.2993268423014", - "meter/current": "2.451247195176256", + "meter/active-power": "-738.5088737412302", + "meter/current": "3.077120307255126", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -333,15 +333,15 @@ "switch/relay-requester": "NONE" }, "afe90839f2725e3e962fb05afa2b6d43": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128457, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Chest Freezer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923433, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Chest Freezer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Chest Freezer", "info/spaces": "19", "load-shed/priority": "NEVER", - "meter/active-power": "-87.99770926735235", - "meter/current": "0.7333142438946029", + "meter/active-power": "-75.59263775126298", + "meter/current": "0.6299386479271916", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "false", @@ -351,15 +351,15 @@ "switch/relay-requester": "NONE" }, "b24483358d29589d8e91d3bf11113269": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128456, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Office Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923432, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Office Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Office Outlets", "info/spaces": "11", "load-shed/priority": "NEVER", - "meter/active-power": "-309.29811136402657", - "meter/current": "2.5774842613668882", + "meter/active-power": "-329.92180794873804", + "meter/current": "2.749348399572817", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -369,15 +369,15 @@ "switch/relay-requester": "NONE" }, "b9fa08f1eaaf5d129bd5c78e1d5d937f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128459, \"type\": \"energy.ebus.device.circuit\", \"name\": \"kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923436, \"type\": \"energy.ebus.device.circuit\", \"name\": \"kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "kitchen Lights", "info/spaces": "3", "load-shed/priority": "NEVER", - "meter/active-power": "-129.17671003539766", - "meter/current": "1.0764725836283138", + "meter/active-power": "-153.90154675440246", + "meter/current": "1.2825128896200204", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -387,7 +387,7 @@ "switch/relay-requester": "NONE" }, "be7742043a06554aab2a1e38cc776603": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128459, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Oven/Range\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923435, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Oven/Range\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "40", @@ -405,15 +405,15 @@ "switch/relay-requester": "NONE" }, "c058aa11287f50f9b81e5160a0678869": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128455, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bathroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923431, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bathroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Bathroom Lights", "info/spaces": "5", "load-shed/priority": "NEVER", - "meter/active-power": "-3.0169707781369457", - "meter/current": "0.025141423151141214", + "meter/active-power": "-2.9413507735267856", + "meter/current": "0.024511256446056548", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -423,15 +423,15 @@ "switch/relay-requester": "NONE" }, "c339ec7ce7ff521ca7646f9606baff9f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128457, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Guest Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923433, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Guest Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Guest Room Outlets", "info/spaces": "14", "load-shed/priority": "NEVER", - "meter/active-power": "-150.19731329810756", - "meter/current": "1.2516442774842296", + "meter/active-power": "-155.11713699165023", + "meter/current": "1.292642808263752", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -441,15 +441,15 @@ "switch/relay-requester": "NONE" }, "d1ff145887a05b839ede89409c27b398": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128456, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923432, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Garage Outlets", "info/spaces": "12", "load-shed/priority": "NEVER", - "meter/active-power": "-164.6978612658996", - "meter/current": "1.37248217721583", + "meter/active-power": "-127.61056242350686", + "meter/current": "1.0634213535292238", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -459,15 +459,15 @@ "switch/relay-requester": "NONE" }, "e0ac90e169e6550ea83fe0b1942f1d0e": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128456, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923432, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Living Room Outlets", "info/spaces": "8", "load-shed/priority": "NEVER", - "meter/active-power": "-261.26659817397", - "meter/current": "2.17722165144975", + "meter/active-power": "-269.24435321102357", + "meter/current": "2.2437029434251965", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -477,15 +477,15 @@ "switch/relay-requester": "NONE" }, "e0bc156c85015a609d4132084dfcd6fe": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128457, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Microwave\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923433, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Microwave\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Microwave", "info/spaces": "18", "load-shed/priority": "NEVER", - "meter/active-power": "-1500.0", - "meter/current": "12.5", + "meter/active-power": "0.0", + "meter/current": "0.0", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -495,15 +495,15 @@ "switch/relay-requester": "NONE" }, "edee3425d50d51ffb022ee999053b2b4": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128456, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Laundry Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923432, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Laundry Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Laundry Room Outlets", "info/spaces": "13", "load-shed/priority": "NEVER", - "meter/active-power": "-163.67264716924112", - "meter/current": "1.3639387264103426", + "meter/active-power": "-135.20155504982048", + "meter/current": "1.1266796254151707", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -513,15 +513,15 @@ "switch/relay-requester": "NONE" }, "ef972f063451539e8b2ad88e831d87b6": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128458, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Dryer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923434, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Dryer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", "info/name": "Electric Dryer", "info/spaces": "20,22", "load-shed/priority": "OFF_GRID", - "meter/active-power": "0.0", - "meter/current": "0.0", + "meter/active-power": "-5000.0", + "meter/current": "20.833333333333332", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -531,7 +531,7 @@ "switch/relay-requester": "NONE" }, "f515a0f43b6555b1a196fbb62728c24e": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128455, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Exterior Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923431, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Exterior Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", @@ -549,7 +549,7 @@ "switch/relay-requester": "NONE" }, "sim-40t-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128460, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Span Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"sim-40t-001-SIM-BESS-40T-001\", \"770e2de52c33508a8a9ee8878064b46f\", \"9429f828509e58d59cb5f0f9f5fee523\", \"3d9d86f303cc50d1827be57d4c667e53\", \"c058aa11287f50f9b81e5160a0678869\", \"f515a0f43b6555b1a196fbb62728c24e\", \"3eeb0eb1605e5a7eadac41994b7a096c\", \"e0ac90e169e6550ea83fe0b1942f1d0e\", \"80a4fada833156ab8112f9d50e252b8f\", \"13044bfbcbe5554b8f3dba126bce828f\", \"b24483358d29589d8e91d3bf11113269\", \"d1ff145887a05b839ede89409c27b398\", \"edee3425d50d51ffb022ee999053b2b4\", \"c339ec7ce7ff521ca7646f9606baff9f\", \"2140a7e253ed54e3bc90a959081df615\", \"4d1deb6acb065746b13207b1358f8ca7\", \"43a0521737db516f99f14a9964ea4af0\", \"e0bc156c85015a609d4132084dfcd6fe\", \"afe90839f2725e3e962fb05afa2b6d43\", \"4aeb08c46c2c5905a944166413f2f1ef\", \"516694a326a35cd88600b3520e8a981a\", \"1eeeb748eeaa58edb7e9b7e9dbbdeca7\", \"ef972f063451539e8b2ad88e831d87b6\", \"af731c49a6785a4cb2ea5549fb8bce7e\", \"948dea7788aa5c959b99df0edfabead2\", \"be7742043a06554aab2a1e38cc776603\", \"4ce8b30e8d3f5c49b9e0ab0c8caf4832\", \"249a2f59782e5f1ab317c4632e79afad\", \"1bfdc7ecebb0547bbe87a3696cddb0c0\", \"6fcb352679ad5bfb8c8a8eab06829b9f\", \"b9fa08f1eaaf5d129bd5c78e1d5d937f\", \"sim-40t-001-SIM-EVSE-001\", \"sim-40t-001-SIM-EVSE-002\", \"sim-40t-001-lugs-up\", \"sim-40t-001-lugs-dn\", \"sim-40t-001-pv-1\"], \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923436, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Span Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"sim-40t-001-SIM-BESS-40T-001\", \"770e2de52c33508a8a9ee8878064b46f\", \"9429f828509e58d59cb5f0f9f5fee523\", \"3d9d86f303cc50d1827be57d4c667e53\", \"c058aa11287f50f9b81e5160a0678869\", \"f515a0f43b6555b1a196fbb62728c24e\", \"3eeb0eb1605e5a7eadac41994b7a096c\", \"e0ac90e169e6550ea83fe0b1942f1d0e\", \"80a4fada833156ab8112f9d50e252b8f\", \"13044bfbcbe5554b8f3dba126bce828f\", \"b24483358d29589d8e91d3bf11113269\", \"d1ff145887a05b839ede89409c27b398\", \"edee3425d50d51ffb022ee999053b2b4\", \"c339ec7ce7ff521ca7646f9606baff9f\", \"2140a7e253ed54e3bc90a959081df615\", \"4d1deb6acb065746b13207b1358f8ca7\", \"43a0521737db516f99f14a9964ea4af0\", \"e0bc156c85015a609d4132084dfcd6fe\", \"afe90839f2725e3e962fb05afa2b6d43\", \"4aeb08c46c2c5905a944166413f2f1ef\", \"516694a326a35cd88600b3520e8a981a\", \"1eeeb748eeaa58edb7e9b7e9dbbdeca7\", \"ef972f063451539e8b2ad88e831d87b6\", \"af731c49a6785a4cb2ea5549fb8bce7e\", \"948dea7788aa5c959b99df0edfabead2\", \"be7742043a06554aab2a1e38cc776603\", \"4ce8b30e8d3f5c49b9e0ab0c8caf4832\", \"249a2f59782e5f1ab317c4632e79afad\", \"1bfdc7ecebb0547bbe87a3696cddb0c0\", \"6fcb352679ad5bfb8c8a8eab06829b9f\", \"b9fa08f1eaaf5d129bd5c78e1d5d937f\", \"sim-40t-001-SIM-EVSE-sim-40t-001\", \"sim-40t-001-SIM-EVSE-sim-40t-001-2\", \"sim-40t-001-lugs-up\", \"sim-40t-001-lugs-dn\", \"sim-40t-001-pv-1\"], \"extensions\": []}", "$state": "ready", "breaker/rating": "200", "door/state": "CLOSED", @@ -577,10 +577,10 @@ "pcs/requested-import-limit": "0.0", "pcs/requested-import-limit-active": "false", "pcs/requested-import-limit-enablement": "UNCONFIGURED", - "power-flows/battery": "246.26174900301885", - "power-flows/grid": "0.0", - "power-flows/pv": "9193.069021378888", - "power-flows/site": "9439.330770381906", + "power-flows/battery": "3500.0", + "power-flows/grid": "3477.2209580173558", + "power-flows/pv": "7327.47708818823", + "power-flows/site": "14304.698046205585", "shed-forecast/confidence": "HIGH", "shed-forecast/full-charge-time-to-priority-shed": "3038", "shed-forecast/full-charge-total-time-remaining": "4320", @@ -596,20 +596,20 @@ "status/wifi": "true" }, "sim-40t-001-SIM-BESS-40T-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128460, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"sim-40t-001-SIM-BESS-40T-001-mid\"], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923436, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"sim-40t-001-SIM-BESS-40T-001-mid\"], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "info/model": "SPAN Battery", "info/nameplate-capacity": "13.5", "info/part-number": "SPN-BESS-001", "info/serial-number": "SIM-BESS-40T-001", "info/vendor-name": "Span", - "meter/active-power": "246.26174900301885", + "meter/active-power": "3500.0", "soc/soc": "50.0", "soc/soe": "6.75", "status/communication-state": "OK" }, "sim-40t-001-SIM-BESS-40T-001-mid": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128460, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001-SIM-BESS-40T-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923436, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001-SIM-BESS-40T-001\", \"extensions\": []}", "$state": "ready", "grid/grid-forming-entity": "GRID", "grid/grid-state": "UP", @@ -617,36 +617,36 @@ "info/serial-number": "SIM-BESS-40T-001-mid", "info/vendor-name": "Span" }, - "sim-40t-001-SIM-EVSE-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128460, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "sim-40t-001-SIM-EVSE-sim-40t-001": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923436, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "config/max-charge-current": "32", "config/user-max-charge-current": "32", "info/firmware-version": "sim/v0.1.0", "info/model": "SPAN Drive", "info/part-number": "SPN-DRV-001", - "info/serial-number": "SIM-EVSE-001", + "info/serial-number": "SIM-EVSE-sim-40t-001", "info/vendor-name": "SPAN", "meter/advertised-current": "32.0", "status/status": "AVAILABLE", "switch/lock-state": "UNLOCKED" }, - "sim-40t-001-SIM-EVSE-002": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128460, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "sim-40t-001-SIM-EVSE-sim-40t-001-2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923436, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "config/max-charge-current": "32", "config/user-max-charge-current": "32", "info/firmware-version": "sim/v0.1.0", "info/model": "SPAN Drive", "info/part-number": "SPN-DRV-001", - "info/serial-number": "SIM-EVSE-002", + "info/serial-number": "SIM-EVSE-sim-40t-001-2", "info/vendor-name": "SPAN", "meter/advertised-current": "32.0", "status/status": "AVAILABLE", "switch/lock-state": "UNLOCKED" }, "sim-40t-001-lugs-dn": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128460, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923436, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "info/direction": "DOWNSTREAM", "meter/active-power": "0", @@ -656,20 +656,20 @@ "meter/imported-energy": "0" }, "sim-40t-001-lugs-up": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128460, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923436, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "connection/fed-by-device-id": "sim-40t-001-SIM-BESS-40T-001", "connection/fed-by-device-status": "OK", "connection/fed-by-device-type": "energy.ebus.device.bess", "info/direction": "UPSTREAM", - "meter/active-power": "246.26174900301885", - "meter/current-a": "76.43141628782878", - "meter/current-b": "78.8385819768445", + "meter/active-power": "6977.220958017356", + "meter/current-a": "99.37672150823833", + "meter/current-b": "80.89140461171012", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0" }, "sim-40t-001-pv-1": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786394128460, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786400923436, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "info/model": "IQ8PLUS-72-2-US", "info/nominal-power": "10000.0", diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json index 4d7c565..6962f59 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -14,7 +14,7 @@ "repo": "https://github.com/SpanPanel/panelbench", "ref": "feat/adopt-upstream-emitter", "role": "publisher", - "commit": "38fb6343cc8afc1cecedb720a4418321a1889501", + "commit": "c83c56cced43e27eaf1c2e6f436b56239cde0b42", "synced_commit": "4254526b0a8c11cab0d40fd700b1fc295c0479c6", "firmware_range": "r202633+", "fixtures": { From d238b3b7ef8020ada9f644628434a742472b7528 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:48:24 -0700 Subject: [PATCH 056/115] fix(schema_1): harmonise EVSE identity so the migration is invisible to consumers An EVSE entity's `unique_id` and its device-registry `identifiers` are both built from what this library hands over -- the snapshot dict key and `SpanEvseSnapshot.node_id`: for n in snapshot.evse: # sensor.py -> f"span_{serial}_evse_{n}_{description_key}" identifiers={(DOMAIN, f"{panel}_evse_{evse.node_id}")} # util.py:60 schema_1 keyed both on the v1.0 device id while schema_0 keys on firmware's node name, so on upgrade every charger would orphan and reappear as a new device beside it, history stranded. Confirmed against the integration source, not inferred. Harmonising belongs here. This library is the seam whose whole purpose is that the integration never learns which wire schema is underneath; the alternative -- a one-time `unique_id` migration upstairs -- would be an admission that the seam failed, and would have to ship ahead of the firmware to be any use. `_harmonised_evse_keys` reconstructs flat's keys. Which physical Drive is which is not guesswork: the feed circuit correlates them, and circuit UUIDs are the identity already proven to survive (30/30). Measured on the paired captures: flat evse feed 249a2f59... v1.0 circuit 249a2f59... feeds ...-001 flat evse-2 feed 1bfdc7ec... v1.0 circuit 1bfdc7ec... feeds ...-001-2 The ordering is the one assumption. Which of those becomes `evse` rather than `evse-2` is firmware's enumeration order and v1.0 does not publish it, so it is reconstructed from the feed circuit's lowest tab -- which reproduces flat's assignment on the only paired capture available. A single-EVSE install cannot be affected by that choice, which is the common case; a two-Drive install is where a wrong guess swaps two chargers, and that is the piece worth confirming with SPAN. `build_evse` now takes `node_id` explicitly rather than reading `device_id`, because the two are no longer the same thing and the device-registry identifier depends on which one it gets. `test_evse_identity_does_not_survive_the_migration` recorded the break; it is now `test_evse_identity_survives_the_migration` and asserts the fix, including that `node_id` tracks the key. Falsified: reverting the harmoniser to device ids fails it with the disjoint sets. 592 passed, mypy clean. --- .../src/span_panel_api_schema_1/devices.py | 11 ++- .../src/span_panel_api_schema_1/snapshot.py | 61 +++++++++++++++- tests/test_schema_migration_delta.py | 73 +++++++++---------- tests/test_schema_one_devices.py | 4 +- 4 files changed, 105 insertions(+), 44 deletions(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/devices.py b/packages/schema-1/src/span_panel_api_schema_1/devices.py index f08c7c1..023ae8a 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/devices.py +++ b/packages/schema-1/src/span_panel_api_schema_1/devices.py @@ -127,10 +127,15 @@ def build_pv(pv: DiscoveredDevice | None, feeds: dict[str, str]) -> SpanPVSnapsh ) -def build_evse(evse: DiscoveredDevice, feeds: dict[str, str]) -> SpanEvseSnapshot: - """Build one EVSE snapshot.""" +def build_evse(evse: DiscoveredDevice, feeds: dict[str, str], *, node_id: str) -> SpanEvseSnapshot: + """Build one EVSE snapshot. + + `node_id` is supplied rather than taken from `evse.device_id`: it feeds the + integration's device-registry `identifiers`, so it has to be the harmonised + key, not the v1.0 device id. See `_harmonised_evse_keys`. + """ return SpanEvseSnapshot( - node_id=evse.device_id, + node_id=node_id, feed_circuit_id=feeds.get(evse.device_id, ""), status=text(evse, NODE_STATUS, PROP_STATUS, UNKNOWN), lock_state=text(evse, NODE_SWITCH, PROP_LOCK_STATE, UNKNOWN), diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index d251ac2..4c3d243 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -28,8 +28,12 @@ from span_panel_api_schema_1.panel import PanelFields, build_unmapped_tabs, find_lugs, panel_size_from_model, text if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from ebus_sdk.homie import DiscoveredDevice + from span_panel_api.models import SpanCircuitSnapshot + def device_type(device: DiscoveredDevice) -> str: """The device's declared type from its description, or '' before it arrives. @@ -151,5 +155,60 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re circuits=circuits, battery=build_battery(roles.bess, owners), pv=build_pv(roles.pv, feeds), - evse={device.device_id: build_evse(device, feeds) for device in roles.evse}, + evse={ + key: build_evse(device, feeds, node_id=key) + for device, key in _harmonised_evse_keys(roles.evse, feeds, circuits).items() + }, ) + + +_UNPLACED_EVSE = 1_000_000 +"""Sort key for an EVSE whose feed circuit is unknown: last, and deterministically.""" + + +def _harmonised_evse_keys( + evse_devices: Sequence[DiscoveredDevice], + feeds: Mapping[str, str], + circuits: Mapping[str, SpanCircuitSnapshot], +) -> dict[DiscoveredDevice, str]: + """Give each EVSE the key flat firmware would have used for it. + + **This library is the harmonisation layer.** The integration builds an EVSE + entity's `unique_id` and its device-registry `identifiers` from what it finds + here, so a key that changes between schemas orphans a user's charger and stands + a duplicate up beside it. Presenting the same handle for the same physical + device is this seam's job, not the integration's — an adapter that pushed a + `unique_id` migration upstairs would be admitting the seam failed. + + Flat does not choose these keys: `schema_0` writes `result[node_id]` with the + wire node id verbatim, so `evse` / `evse-2` are *firmware's* names. Nothing in + the v1.0 tree carries them, so they have to be reconstructed. + + **Which physical Drive is which is not guesswork.** The feed circuit correlates + them, and circuit UUIDs are the one identity proven to survive the migration + (30/30 in `test_schema_migration_delta.py`). Measured on the paired captures: + + flat evse feed 249a2f59... v1.0 circuit 249a2f59... feeds ...-001 + flat evse-2 feed 1bfdc7ec... v1.0 circuit 1bfdc7ec... feeds ...-001-2 + + **The ordering is the assumption, and it is narrow.** Which of those becomes + `evse` rather than `evse-2` is firmware's enumeration order, which v1.0 does not + publish. Ordering by the feed circuit's lowest tab reproduces flat's assignment + on the only paired capture available, and matches how a panel would plausibly + enumerate its own breakers. It is not confirmed by SPAN. + + Blast radius if the assumption is wrong: **none for a single-EVSE install**, + where there is nothing to order and the key is `evse` either way — which is the + common case. A two-Drive install would swap the pair, so the two chargers trade + histories. That is the risk worth confirming with SPAN, and it is the reason the + rule is one readable function rather than an inline `sorted()`. + """ + + def position(device: DiscoveredDevice) -> tuple[int, str]: + circuit = circuits.get(feeds.get(device.device_id, "")) + first_tab = min(circuit.tabs) if circuit is not None and circuit.tabs else _UNPLACED_EVSE + # device_id breaks ties so the order is total, never insertion-dependent. + return (first_tab, device.device_id) + + ordered = sorted(evse_devices, key=position) + return {device: ("evse" if index == 0 else f"evse-{index + 1}") for index, device in enumerate(ordered)} diff --git a/tests/test_schema_migration_delta.py b/tests/test_schema_migration_delta.py index 941e3a4..7b78c4e 100644 --- a/tests/test_schema_migration_delta.py +++ b/tests/test_schema_migration_delta.py @@ -296,47 +296,44 @@ def test_every_circuit_keeps_its_identity_across_the_migration(flat: Any, parent ), "circuit identities diverge across the migration; every non-matching circuit loses its recorder history" -def test_evse_identity_does_not_survive_the_migration(flat: Any, parent_child: Any) -> None: - """The circuit test's answer, inverted — and only visible once the ids were right. - - Flat keys an EVSE by its node name (`evse`, `evse-2`). v1.0 keys it by the - proxied device id the migration guide specifies, `-`. - Those are disjoint, so nothing carries over. - - **This was invisible until 2026-08-10.** The v1.0 producer published bare - `evse` / `evse-2` — flat-shaped ids that no panel publishes — inherited from an - example script. Both sides matched, the premise check passed, and EVSE identity - looked as safe as circuit identity. Correcting the producer's ids - (panelbench `38fb634`) made the two sides disagree, which is the true state. - - **What this does and does not establish.** It establishes that the snapshot key - changes, and the snapshot key is what an `evse` entity's identity is built from - here. It does *not* establish that a user loses EVSE history: the integration - still pins `2.6.4` and is not on this adapter, so how it derives an EVSE - `unique_id` cannot be read from this repository. Nor is the flat side attested — - the frozen simulator supplies it and the one live panel available has no Drives, - so real flat firmware's EVSE identity is unverified. - - So this is a *finding pending firmware confirmation*, not a settled break. It is - asserted rather than left to a document because the failure mode it guards - against is the one that already happened: a producer detail quietly making the - comparison come out reassuring. - - Circuits, by contrast, are attested and identical — see - `test_every_circuit_keeps_its_identity_across_the_migration`. +def test_evse_identity_survives_the_migration(flat: Any, parent_child: Any) -> None: + """The circuit test's answer, for the other device class that carries an identity. + + An EVSE entity's `unique_id` and its device-registry `identifiers` are both built + from what this library hands over -- the snapshot key and `node_id`. So if those + move between schemas, a user's charger orphans and a duplicate appears beside it. + Keeping them still is this seam's job: the integration is not supposed to know + which wire schema is underneath, and an adapter that needed a `unique_id` + migration upstairs would be an adapter that failed. + + **This was briefly broken, and the way it hid is the lesson.** v1.0 keyed EVSEs by + device id while flat keys by firmware's node name, so the two disagreed. It went + unnoticed for as long as it did because the v1.0 *producer* published bare + `evse` / `evse-2` -- flat-shaped ids no panel emits, copied from an example + script -- which made both sides match and this comparison read clean. Correcting + the producer's ids (panelbench `38fb634`) exposed the disagreement; harmonising + the keys in `_harmonised_evse_keys` closed it. + + What the harmoniser assumes is stated there and worth repeating here: which Drive + becomes `evse` rather than `evse-2` is firmware's enumeration order, which v1.0 + does not publish, so it is reconstructed from the feed circuit's lowest tab. A + single-EVSE install cannot be affected by that choice. A two-Drive install is + where a wrong guess would swap two chargers' histories, and it is the one thing + here still worth confirming with SPAN. """ - assert len(flat.evse) == len(parent_child.evse) > 0, "the two captures model different EVSE counts" - - assert not (set(flat.evse) & set(parent_child.evse)), ( - "EVSE identities now overlap across the migration. If the producer's ids were " - "corrected toward - this should be disjoint; an overlap " - "means something reintroduced flat-shaped ids on the v1.0 side, which is what " - "hid this break until 2026-08-10." + assert set(flat.evse) == set(parent_child.evse), ( + "EVSE identities diverge across the migration; every non-matching charger " + "orphans its history and reappears as a new device" ) - assert all( - key.startswith(f"{_SERIAL}-") for key in parent_child.evse - ), f"v1.0 EVSE keys should be - with this panel as proxier, got {sorted(parent_child.evse)}" + for key, evse in parent_child.evse.items(): + assert evse.node_id == key, ( + f"{key}: node_id drives the device-registry identifier and must match the " f"snapshot key, got {evse.node_id!r}" + ) + + assert {evse.feed_circuit_id for evse in flat.evse.values()} == { + evse.feed_circuit_id for evse in parent_child.evse.values() + }, "the two captures feed their EVSEs from different circuits, so they are not the same panel" def test_no_circuit_field_is_orphaned(flat: Any, parent_child: Any) -> None: diff --git a/tests/test_schema_one_devices.py b/tests/test_schema_one_devices.py index d09fcdc..5f93d5a 100644 --- a/tests/test_schema_one_devices.py +++ b/tests/test_schema_one_devices.py @@ -151,7 +151,7 @@ def test_no_pv_yields_the_empty_snapshot() -> None: def test_evse_state_and_metadata() -> None: - evse = build_evse(_device("evse"), {}) + evse = build_evse(_device("evse"), {}, node_id="evse") assert evse.node_id == "evse" assert evse.status == "CHARGING" @@ -166,4 +166,4 @@ def test_evse_state_and_metadata() -> None: def test_evse_without_a_feeding_circuit_reports_empty_not_none() -> None: """`feed_circuit_id` is non-optional on the dataclass, so an unclaimed EVSE gets the empty string rather than breaking construction.""" - assert build_evse(_device("evse"), {}).feed_circuit_id == "" + assert build_evse(_device("evse"), {}, node_id="evse").feed_circuit_id == "" From 1d3c2f0f0eea86f3438b9daf129fafc3c93df1c2 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:10:17 -0700 Subject: [PATCH 057/115] docs(schema_1): the tab-order evidence does not discriminate, say so The docstring claimed ordering by feed-circuit tab "reproduces flat's assignment on the only paired capture available". True, and vacuous: that capture's config lists the two Drives in the same order as their tabs, so tab-order and commissioning-order predict the same answer and the capture cannot tell them apart. Also records why info/serial-number does not settle it, since that is the obvious objection. The serial is published on both sides and identifies the same physical Drive on each -- but the harmoniser needs to know what *flat* called that Drive, and that association exists only in flat's tree, which schema_1 never sees. Checked what a v1.0 EVSE actually publishes: no ordinal, no node name, nothing that recalls the flat identity. No behaviour change. 592 passed. --- .../src/span_panel_api_schema_1/snapshot.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index 4c3d243..ad7bd73 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -191,11 +191,15 @@ def _harmonised_evse_keys( flat evse feed 249a2f59... v1.0 circuit 249a2f59... feeds ...-001 flat evse-2 feed 1bfdc7ec... v1.0 circuit 1bfdc7ec... feeds ...-001-2 - **The ordering is the assumption, and it is narrow.** Which of those becomes - `evse` rather than `evse-2` is firmware's enumeration order, which v1.0 does not - publish. Ordering by the feed circuit's lowest tab reproduces flat's assignment - on the only paired capture available, and matches how a panel would plausibly - enumerate its own breakers. It is not confirmed by SPAN. + **The ordering is the assumption, and the evidence for it is weaker than it + looks.** Which of those becomes `evse` rather than `evse-2` is firmware's + enumeration order, and v1.0 publishes no ordinal, no node name, nothing that + recalls the flat identity -- `info/serial-number` says which physical Drive this + is, never what flat called it. Ordering by the feed circuit's lowest tab does + reproduce flat's assignment on the paired capture, but that capture cannot + discriminate: its config lists the Drives in the same order as their tabs, so + tab-order and commissioning-order predict the same answer. The rule is a + plausible reconstruction, not a measured one, and SPAN has not confirmed it. Blast radius if the assumption is wrong: **none for a single-EVSE install**, where there is nothing to order and the key is `evse` either way — which is the From a389d9d1eabb6859c5707a2cb4ac011a71eab0c6 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:44:42 -0700 Subject: [PATCH 058/115] fix(schema_1): key EVSE by serial, which is what flat firmware keys it by Replaces the tab-order harmoniser from d238b3b, which reconstructed a naming scheme that does not exist on any panel. SpanPanel/span#214 is a live panel through a Drive replacement, and it settles what flat actually publishes. The reporter's topic is `ebus/5//`; their diagnostics show the snapshot keyed `"evse": {"dt-2302-c1km3": ...}`; and the thread turns on that node id being what the `unique_id` is built from. So on real firmware **the EVSE node id is the Drive's serial**, and `schema_0` -- which writes `result[node_id]` verbatim -- is already serial-keyed without knowing it. v1.0 names the same device `-`. Stripping to the serial reproduces flat's key exactly, so a charger keeps its `unique_id` and its device-registry entry across the upgrade. The proxy model prescribes the same thing independently: a proxied device id is not stable across the proxy-to-native transition, and "consumers that need cross-transition stable identity use `info/serial-number`". **The frozen flat simulator names its EVSE nodes `evse` / `evse-2`** -- positional slots no panel publishes. That infidelity is what produced the previous design: it made this look like an ordering problem, so the old harmoniser reconstructed firmware's enumeration from feed-circuit tab order, carried an assumption it could not test, and left a question for SPAN that did not need asking. There was no ordering problem, and there is no swap risk for multi-Drive installs. The simulator gap is recorded in the delta document rather than compensated for here, because the right behaviour is the one firmware exhibits. `test_evse_identity_survives_the_migration` now compares v1.0's keys against flat's *serials* rather than flat's keys, for the same reason -- asserting against the simulator's node names is what sent this wrong the first time. Falsified: reverting to device-id keys fails it with the disjoint sets. An EVSE with no serial keeps its device id. Inventing an ordinal is the thing this function exists to avoid. 592 passed, mypy clean. --- .../src/span_panel_api_schema_1/snapshot.py | 89 +++++++------------ tests/test_schema_migration_delta.py | 42 +++++---- tests/test_schema_one_snapshot.py | 8 +- 3 files changed, 58 insertions(+), 81 deletions(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index ad7bd73..fdeae46 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -16,6 +16,7 @@ from span_panel_api_schema_1.const import ( NODE_INFO, PROP_MODEL, + PROP_SERIAL_NUMBER, TYPE_BESS, TYPE_CIRCUIT, TYPE_EVSE, @@ -28,12 +29,10 @@ from span_panel_api_schema_1.panel import PanelFields, build_unmapped_tabs, find_lugs, panel_size_from_model, text if TYPE_CHECKING: - from collections.abc import Mapping, Sequence + from collections.abc import Sequence from ebus_sdk.homie import DiscoveredDevice - from span_panel_api.models import SpanCircuitSnapshot - def device_type(device: DiscoveredDevice) -> str: """The device's declared type from its description, or '' before it arrives. @@ -155,64 +154,40 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re circuits=circuits, battery=build_battery(roles.bess, owners), pv=build_pv(roles.pv, feeds), - evse={ - key: build_evse(device, feeds, node_id=key) - for device, key in _harmonised_evse_keys(roles.evse, feeds, circuits).items() - }, + evse={key: build_evse(device, feeds, node_id=key) for device, key in _harmonised_evse_keys(roles.evse).items()}, ) -_UNPLACED_EVSE = 1_000_000 -"""Sort key for an EVSE whose feed circuit is unknown: last, and deterministically.""" - - -def _harmonised_evse_keys( - evse_devices: Sequence[DiscoveredDevice], - feeds: Mapping[str, str], - circuits: Mapping[str, SpanCircuitSnapshot], -) -> dict[DiscoveredDevice, str]: - """Give each EVSE the key flat firmware would have used for it. +def _harmonised_evse_keys(evse_devices: Sequence[DiscoveredDevice]) -> dict[DiscoveredDevice, str]: + """Key each EVSE by its serial, which is what flat firmware keys it by. **This library is the harmonisation layer.** The integration builds an EVSE entity's `unique_id` and its device-registry `identifiers` from what it finds - here, so a key that changes between schemas orphans a user's charger and stands - a duplicate up beside it. Presenting the same handle for the same physical - device is this seam's job, not the integration's — an adapter that pushed a - `unique_id` migration upstairs would be admitting the seam failed. - - Flat does not choose these keys: `schema_0` writes `result[node_id]` with the - wire node id verbatim, so `evse` / `evse-2` are *firmware's* names. Nothing in - the v1.0 tree carries them, so they have to be reconstructed. - - **Which physical Drive is which is not guesswork.** The feed circuit correlates - them, and circuit UUIDs are the one identity proven to survive the migration - (30/30 in `test_schema_migration_delta.py`). Measured on the paired captures: - - flat evse feed 249a2f59... v1.0 circuit 249a2f59... feeds ...-001 - flat evse-2 feed 1bfdc7ec... v1.0 circuit 1bfdc7ec... feeds ...-001-2 - - **The ordering is the assumption, and the evidence for it is weaker than it - looks.** Which of those becomes `evse` rather than `evse-2` is firmware's - enumeration order, and v1.0 publishes no ordinal, no node name, nothing that - recalls the flat identity -- `info/serial-number` says which physical Drive this - is, never what flat called it. Ordering by the feed circuit's lowest tab does - reproduce flat's assignment on the paired capture, but that capture cannot - discriminate: its config lists the Drives in the same order as their tabs, so - tab-order and commissioning-order predict the same answer. The rule is a - plausible reconstruction, not a measured one, and SPAN has not confirmed it. - - Blast radius if the assumption is wrong: **none for a single-EVSE install**, - where there is nothing to order and the key is `evse` either way — which is the - common case. A two-Drive install would swap the pair, so the two chargers trade - histories. That is the risk worth confirming with SPAN, and it is the reason the - rule is one readable function rather than an inline `sorted()`. + here, so a key that changes between schemas orphans a user's charger and stands a + duplicate up beside it. Presenting the same handle for the same physical device is + this seam's job, not the integration's. + + On real flat firmware the EVSE **node id is the Drive's serial**. Confirmed on a + live panel in SpanPanel/span#214: the reporter's topic is + `ebus/5//`, their diagnostics show the snapshot keyed + `"evse": {"dt-2302-c1km3": ...}`, and the whole thread turns on that node id being + what the `unique_id` is built from. `schema_0` writes `result[node_id]` verbatim, + so against firmware it is already serial-keyed without knowing it. + + v1.0 names the same device `-`, the proxied form, so stripping to + the serial reproduces flat's key exactly. The proxy model prescribes the same + thing independently: `devices/proxy.md` says a proxied device id is *not* stable + across the proxy-to-native transition, and that "consumers that need + cross-transition stable identity use `info/serial-number`". + + **The flat simulator does not do this**, and it briefly cost us the wrong design. + It names EVSE nodes `evse` / `evse-2` -- positional slots no panel publishes -- + which made this look like an ordering problem, needing a rule to reconstruct + firmware's enumeration and needing SPAN to confirm that rule. There was no + ordering problem; there was an unfaithful fixture. + + A device with no serial keeps its v1.0 device id. Inventing an ordinal is the + thing this function exists to avoid, and an unkeyable EVSE is better left + obviously distinct than quietly merged with another. """ - - def position(device: DiscoveredDevice) -> tuple[int, str]: - circuit = circuits.get(feeds.get(device.device_id, "")) - first_tab = min(circuit.tabs) if circuit is not None and circuit.tabs else _UNPLACED_EVSE - # device_id breaks ties so the order is total, never insertion-dependent. - return (first_tab, device.device_id) - - ordered = sorted(evse_devices, key=position) - return {device: ("evse" if index == 0 else f"evse-{index + 1}") for index, device in enumerate(ordered)} + return {device: (text(device, NODE_INFO, PROP_SERIAL_NUMBER) or device.device_id) for device in evse_devices} diff --git a/tests/test_schema_migration_delta.py b/tests/test_schema_migration_delta.py index 7b78c4e..7f4e397 100644 --- a/tests/test_schema_migration_delta.py +++ b/tests/test_schema_migration_delta.py @@ -300,30 +300,28 @@ def test_evse_identity_survives_the_migration(flat: Any, parent_child: Any) -> N """The circuit test's answer, for the other device class that carries an identity. An EVSE entity's `unique_id` and its device-registry `identifiers` are both built - from what this library hands over -- the snapshot key and `node_id`. So if those + from what this library hands over -- the snapshot key and `node_id` -- so if those move between schemas, a user's charger orphans and a duplicate appears beside it. - Keeping them still is this seam's job: the integration is not supposed to know - which wire schema is underneath, and an adapter that needed a `unique_id` - migration upstairs would be an adapter that failed. - - **This was briefly broken, and the way it hid is the lesson.** v1.0 keyed EVSEs by - device id while flat keys by firmware's node name, so the two disagreed. It went - unnoticed for as long as it did because the v1.0 *producer* published bare - `evse` / `evse-2` -- flat-shaped ids no panel emits, copied from an example - script -- which made both sides match and this comparison read clean. Correcting - the producer's ids (panelbench `38fb634`) exposed the disagreement; harmonising - the keys in `_harmonised_evse_keys` closed it. - - What the harmoniser assumes is stated there and worth repeating here: which Drive - becomes `evse` rather than `evse-2` is firmware's enumeration order, which v1.0 - does not publish, so it is reconstructed from the feed circuit's lowest tab. A - single-EVSE install cannot be affected by that choice. A two-Drive install is - where a wrong guess would swap two chargers' histories, and it is the one thing - here still worth confirming with SPAN. + + **The comparison is against firmware, not against the flat simulator.** On a real + panel the EVSE node id *is* the Drive's serial: SpanPanel/span#214 has the topic + `ebus/5//`, diagnostics keyed + `"evse": {"dt-2302-c1km3": ...}`, and a maintainer confirming that node id is what + the `unique_id` is built from. The frozen flat simulator instead names its nodes + `evse` / `evse-2`, positional slots no panel publishes -- so `set(flat.evse)` is + the wrong thing to assert against, and asserting it is what previously produced an + elaborate reconstruction of a naming scheme that does not exist. + + So flat's *serials* stand in for flat's keys, which is what firmware would have + published. The simulator gap is recorded in the delta document. """ - assert set(flat.evse) == set(parent_child.evse), ( - "EVSE identities diverge across the migration; every non-matching charger " - "orphans its history and reappears as a new device" + flat_identity = {evse.serial_number for evse in flat.evse.values()} + assert flat_identity == {None} or None not in flat_identity, "a flat EVSE published no serial to key on" + + assert set(parent_child.evse) == flat_identity, ( + "v1.0 EVSE keys do not match the serials flat publishes. On real firmware the " + "flat node id is that serial, so a mismatch here is a charger that orphans its " + "history and returns as a new device." ) for key, evse in parent_child.evse.items(): diff --git a/tests/test_schema_one_snapshot.py b/tests/test_schema_one_snapshot.py index 9501c50..ed110c6 100644 --- a/tests/test_schema_one_snapshot.py +++ b/tests/test_schema_one_snapshot.py @@ -92,8 +92,12 @@ def test_der_snapshots_are_populated(snapshot: SpanPanelSnapshot) -> None: assert snapshot.battery.connected is True assert snapshot.pv.product_name == "IQ8PLUS-72-2-US" assert snapshot.pv.feed_circuit_id == SOLAR_CIRCUIT - assert set(snapshot.evse) == {"evse", "evse-2"} - assert snapshot.evse["evse"].status == "CHARGING" + # Keyed by serial, not by device id: on real flat firmware the EVSE node id is + # the Drive's serial (SpanPanel/span#214), so this is what keeps a charger's + # `unique_id` still across the migration. The reference tree's bare `evse` / + # `evse-2` device ids are the simulator's naming, not a panel's. + assert set(snapshot.evse) == {"SIM-EVSE-example-40t-001", "SIM-EVSE-example-40t-001-2"} + assert snapshot.evse["SIM-EVSE-example-40t-001"].status == "CHARGING" def test_panel_and_lugs_values_reach_the_snapshot(snapshot: SpanPanelSnapshot) -> None: From 3f8b3c15de7d5e66a2d26a1e76ccc8e1a84e93c5 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:48:25 -0700 Subject: [PATCH 059/115] docs(schema_1): cite span#214 without copying a customer's Drive serial The evidence for serial-keyed EVSE nodes came from a reporter's diagnostics in a public issue, and the docstrings quoted their Drive serial verbatim. The issue number carries the citation; the serial identifies a stranger's hardware and does not belong in this repository. Replaced with a placeholder. Present in the a389d9d blobs; not worth rewriting history over, and the value is public in the issue either way. --- packages/schema-1/src/span_panel_api_schema_1/snapshot.py | 2 +- tests/test_schema_migration_delta.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index fdeae46..48840f2 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -170,7 +170,7 @@ def _harmonised_evse_keys(evse_devices: Sequence[DiscoveredDevice]) -> dict[Disc On real flat firmware the EVSE **node id is the Drive's serial**. Confirmed on a live panel in SpanPanel/span#214: the reporter's topic is `ebus/5//`, their diagnostics show the snapshot keyed - `"evse": {"dt-2302-c1km3": ...}`, and the whole thread turns on that node id being + `"evse": {"": ...}`, and the whole thread turns on that node id being what the `unique_id` is built from. `schema_0` writes `result[node_id]` verbatim, so against firmware it is already serial-keyed without knowing it. diff --git a/tests/test_schema_migration_delta.py b/tests/test_schema_migration_delta.py index 7f4e397..dabc290 100644 --- a/tests/test_schema_migration_delta.py +++ b/tests/test_schema_migration_delta.py @@ -306,7 +306,7 @@ def test_evse_identity_survives_the_migration(flat: Any, parent_child: Any) -> N **The comparison is against firmware, not against the flat simulator.** On a real panel the EVSE node id *is* the Drive's serial: SpanPanel/span#214 has the topic `ebus/5//`, diagnostics keyed - `"evse": {"dt-2302-c1km3": ...}`, and a maintainer confirming that node id is what + `"evse": {"": ...}`, and a maintainer confirming that node id is what the `unique_id` is built from. The frozen flat simulator instead names its nodes `evse` / `evse-2`, positional slots no panel publishes -- so `set(flat.evse)` is the wrong thing to assert against, and asserting it is what previously produced an From a644d30eeadcb14c9d059c557fbcdbc566c48ef4 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:19:12 -0700 Subject: [PATCH 060/115] feat(schema_1): surface the MID as its own device v1.0 publishes a Microgrid Interconnect Device -- the islanding authority -- and the enclosure model puts the `grid` capability on it rather than on the enclosure: "Grid connection state, islanding state, and grid-forming-entity identity, published on the enclosure-integrated MID (the enclosure device itself does not publish them)." Until now `schema_1` read `grid/islanding-state` off it for `panel.grid_state` and discarded the device. `SpanMidSnapshot` carries identity plus the three `grid` properties. `panel.grid_state` is untouched: it is the field a user's existing panel entity reads, and this exposes the device the value comes from rather than replacing it. **Purely additive.** No flat panel publishes a MID node -- not the frozen simulator, not the live panel -- so nothing here can orphan an entity anyone has today. Adding a device cannot break an automation that never referenced it, which is the benign cell of the absorb-or-surface policy. Two design choices worth recording. Identity is `info/serial-number`, falling back to the Homie device id. The MID's device id is `-mid`, so it inherits the BESS's proxied form and with it the instability `devices/proxy.md` describes -- a proxied id changes if the device is later published natively, and moves with the panel serial besides. Same reasoning as the EVSE fix, applied before there are users to break rather than after. Falsified: reverting to the device id fails `test_the_mid_identity_is_its_serial`. Presence is `snapshot.mid is not None`, not a sentinel field on an always-present object. `has_bess` in the integration has to guess from `soe_percentage is not None` and its own docstring records that only that one field is reliable; a new optional device should not inherit that. `battery` and `pv` keep their existing shape -- changing those is a separate, breaking decision. Every value except identity is optional. The enclosure model makes `islanding-state` MUST on a MID, but a device mid-discovery has a description and no values yet, and reporting that as ON_GRID would be worse than reporting it as unknown. The reference tree's MID publishes no serial (upstream's example config declares no BESS serial to derive one from), so the fallback path is covered there and the serial path against the SPAN capture. `test_public_api_unchanged` caught the new export, which is the guard working. 595 passed, mypy clean. --- .../src/span_panel_api_schema_1/devices.py | 46 ++++++++++++++++- .../src/span_panel_api_schema_1/snapshot.py | 3 +- src/span_panel_api/__init__.py | 2 + src/span_panel_api/models.py | 50 ++++++++++++++++++- tests/test_public_api_unchanged.py | 3 ++ tests/test_schema_one_against_simulator.py | 19 +++++++ tests/test_schema_one_devices.py | 34 +++++++++++++ 7 files changed, 153 insertions(+), 4 deletions(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/devices.py b/packages/schema-1/src/span_panel_api_schema_1/devices.py index 023ae8a..a264ad6 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/devices.py +++ b/packages/schema-1/src/span_panel_api_schema_1/devices.py @@ -22,8 +22,17 @@ from typing import TYPE_CHECKING -from span_panel_api.models import SpanBatterySnapshot, SpanEvseSnapshot, SpanPVSnapshot -from span_panel_api_schema_1.const import NODE_CONNECTION, NODE_INFO, NODE_METER, NODE_SOC, NODE_STATUS, NODE_SWITCH, UNKNOWN +from span_panel_api.models import SpanBatterySnapshot, SpanEvseSnapshot, SpanMidSnapshot, SpanPVSnapshot +from span_panel_api_schema_1.const import ( + NODE_CONNECTION, + NODE_GRID, + NODE_INFO, + NODE_METER, + NODE_SOC, + NODE_STATUS, + NODE_SWITCH, + UNKNOWN, +) from span_panel_api_schema_1.panel import number, text if TYPE_CHECKING: @@ -146,3 +155,36 @@ def build_evse(evse: DiscoveredDevice, feeds: dict[str, str], *, node_id: str) - serial_number=_optional(text(evse, NODE_INFO, PROP_SERIAL_NUMBER)), software_version=_optional(text(evse, NODE_INFO, PROP_FIRMWARE_VERSION)), ) + + +PROP_ISLANDING_STATE = "islanding-state" +PROP_GRID_STATE = "grid-state" +PROP_GRID_FORMING_ENTITY = "grid-forming-entity" + + +def build_mid(mid: DiscoveredDevice | None) -> SpanMidSnapshot | None: + """Build the MID snapshot, or `None` when the panel publishes no MID. + + `None` is the presence signal, so there is nothing for a consumer to infer from a + sentinel field. Every value is optional except identity: the enclosure model makes + `islanding-state` MUST on a MID, but a device mid-discovery has a description and + no values yet, and reporting that as `ON_GRID` would be worse than reporting it as + unknown. + + Identity follows `SpanEvseSnapshot`: the serial where published, the Homie device + id otherwise. Here the device id is `-mid`, so it inherits the BESS's + proxied form and the instability `devices/proxy.md` warns about — the serial is the + part that survives a proxy-to-native transition. + """ + if mid is None: + return None + serial = _optional(text(mid, NODE_INFO, PROP_SERIAL_NUMBER)) + return SpanMidSnapshot( + node_id=serial or mid.device_id, + serial_number=serial, + vendor_name=_optional(text(mid, NODE_INFO, PROP_VENDOR_NAME)), + model=_optional(text(mid, NODE_INFO, PROP_MODEL)), + islanding_state=_optional(text(mid, NODE_GRID, PROP_ISLANDING_STATE)), + grid_state=_optional(text(mid, NODE_GRID, PROP_GRID_STATE)), + grid_forming_entity=_optional(text(mid, NODE_GRID, PROP_GRID_FORMING_ENTITY)), + ) diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index 48840f2..8ab387a 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -25,7 +25,7 @@ TYPE_PV, UNKNOWN, ) -from span_panel_api_schema_1.devices import build_battery, build_evse, build_pv, feed_circuit_ids +from span_panel_api_schema_1.devices import build_battery, build_evse, build_mid, build_pv, feed_circuit_ids from span_panel_api_schema_1.panel import PanelFields, build_unmapped_tabs, find_lugs, panel_size_from_model, text if TYPE_CHECKING: @@ -154,6 +154,7 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re circuits=circuits, battery=build_battery(roles.bess, owners), pv=build_pv(roles.pv, feeds), + mid=build_mid(roles.mid), evse={key: build_evse(device, feeds, node_id=key) for device, key in _harmonised_evse_keys(roles.evse).items()}, ) diff --git a/src/span_panel_api/__init__.py b/src/span_panel_api/__init__.py index 04994d0..d231312 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -36,6 +36,7 @@ SpanBatterySnapshot, SpanCircuitSnapshot, SpanEvseSnapshot, + SpanMidSnapshot, SpanPanelSnapshot, SpanPVSnapshot, V2AuthResponse, @@ -75,6 +76,7 @@ "SpanBatterySnapshot", "SpanCircuitSnapshot", "SpanEvseSnapshot", + "SpanMidSnapshot", "SpanPVSnapshot", "SpanPanelSnapshot", # Factory diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 84dd76b..1006fcc 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -57,6 +57,45 @@ class SpanPVSnapshot: relative_position: str | None = None # pv/relative-position (IN_PANEL | UPSTREAM | DOWNSTREAM) +@dataclass(frozen=True, slots=True) +class SpanMidSnapshot: + """Microgrid Interconnect Device — the islanding authority. v1.0 only. + + The MID is the device that decides whether the enclosure is islanded, and the + enclosure model puts `grid` on it deliberately: "Grid connection state, islanding + state, and grid-forming-entity identity, published on the enclosure-integrated MID + (the enclosure device itself does not publish them)." + + **Purely additive.** No flat panel publishes a MID node — not the frozen simulator, + not the live panel — so nothing here can orphan an entity a user already has. That + is why this is the benign cell of the absorb-or-surface policy: surfacing a new + device cannot break an automation that never referenced it. + + `panel.grid_state` continues to carry islanding state for the panel entity that has + always shown it. This does not replace that; it exposes the device the value comes + from, so a consumer can render the MID as hardware in its own right. + """ + + node_id: str + """Stable identity, and the device-registry identifier a consumer builds from. + + The serial where published, falling back to the Homie device id — the same choice + as `SpanEvseSnapshot`, for the reason `devices/proxy.md` gives: a proxied device id + is not stable across the proxy-to-native transition, so identity belongs on + `info/serial-number`. + """ + + serial_number: str | None = None + vendor_name: str | None = None + model: str | None = None + islanding_state: str | None = None + """`grid/islanding-state` — ON_GRID / OFF_GRID. MUST on a MID, per the enclosure model.""" + grid_state: str | None = None + """`grid/grid-state` — whether utility power is present, distinct from islanding.""" + grid_forming_entity: str | None = None + """`grid/grid-forming-entity` — which device is currently forming the grid.""" + + @dataclass(frozen=True, slots=True) class SpanEvseSnapshot: """EV Charger (EVSE) state — populated when EVSE node is commissioned.""" @@ -236,4 +275,13 @@ class SpanPanelSnapshot: circuits: dict[str, SpanCircuitSnapshot] = field(default_factory=dict) battery: SpanBatterySnapshot = field(default_factory=SpanBatterySnapshot) pv: SpanPVSnapshot = field(default_factory=SpanPVSnapshot) - evse: dict[str, SpanEvseSnapshot] = field(default_factory=dict) # keyed by node_id + evse: dict[str, SpanEvseSnapshot] = field(default_factory=dict) # keyed by serial (see SpanEvseSnapshot.node_id) + mid: SpanMidSnapshot | None = None + """The islanding authority, when the panel publishes one. v1.0 only. + + `None` rather than an empty instance, deliberately. `has_bess` has to guess + presence from `soe_percentage is not None` because the battery field is always + there, and its own docstring records that only that one field is a reliable + signal. A new optional device should not inherit that: presence is + `snapshot.mid is not None`, with nothing to infer. + """ diff --git a/tests/test_public_api_unchanged.py b/tests/test_public_api_unchanged.py index 3375f6b..4aec4c4 100644 --- a/tests/test_public_api_unchanged.py +++ b/tests/test_public_api_unchanged.py @@ -31,6 +31,9 @@ "SpanBatterySnapshot", "SpanCircuitSnapshot", "SpanEvseSnapshot", + # Added 2026-08-10: v1.0 surfaces the islanding authority as its own device. + # Purely additive -- no flat panel publishes a MID, so nothing existing changes. + "SpanMidSnapshot", "SpanPVSnapshot", "SpanPanelSnapshot", # Factory diff --git a/tests/test_schema_one_against_simulator.py b/tests/test_schema_one_against_simulator.py index a3ea637..5a75552 100644 --- a/tests/test_schema_one_against_simulator.py +++ b/tests/test_schema_one_against_simulator.py @@ -228,3 +228,22 @@ def test_grid_state_is_read_from_the_mid(adapter: SchemaOneAdapter) -> None: "the reader has drifted onto grid/grid-state; None means the producer stopped " "publishing a MID and the mapping is unexercised again." ) + + +def test_the_mid_identity_is_its_serial(adapter: SchemaOneAdapter) -> None: + """The path that decides whether a consumer can keep the MID device still. + + Its Homie device id is `-mid`, so it inherits the BESS's proxied form -- + `--mid` -- and with it the instability `devices/proxy.md` + describes: a proxied id changes if the device is ever published natively, and moves + with the panel serial besides. `info/serial-number` is what survives, and it is what + a device-registry identifier should be built from. Same reasoning as EVSE, applied + before there are any users to break rather than after. + """ + mid = adapter.build_snapshot().mid + + assert mid is not None, "the SPAN capture publishes a MID; the snapshot should carry it" + assert mid.serial_number is not None + assert mid.node_id == mid.serial_number, "identity must be the serial, not the proxied device id" + assert not mid.node_id.startswith(_PANEL), f"the panel prefix is the proxied form, got {mid.node_id!r}" + assert mid.islanding_state == "ON_GRID" diff --git a/tests/test_schema_one_devices.py b/tests/test_schema_one_devices.py index 5f93d5a..de2408a 100644 --- a/tests/test_schema_one_devices.py +++ b/tests/test_schema_one_devices.py @@ -10,6 +10,7 @@ from ebus_sdk.homie import DiscoveredDevice from span_panel_api_schema_1.devices import ( + build_mid, build_battery, build_evse, build_pv, @@ -167,3 +168,36 @@ def test_evse_without_a_feeding_circuit_reports_empty_not_none() -> None: """`feed_circuit_id` is non-optional on the dataclass, so an unclaimed EVSE gets the empty string rather than breaking construction.""" assert build_evse(_device("evse"), {}, node_id="evse").feed_circuit_id == "" + + +def test_the_mid_is_surfaced_as_its_own_device() -> None: + """v1.0's islanding authority, exposed so a consumer can render it as hardware. + + The enclosure model puts `grid` on the MID rather than on the enclosure -- "the + enclosure device itself does not publish them" -- so this is where islanding state, + grid state and the grid-forming entity actually live. + + The reference tree's MID publishes no serial, because upstream's example config + declares no BESS serial for it to derive one from. That exercises the fallback: + identity drops to the Homie device id. `test_the_mid_identity_is_its_serial` covers + the path that matters more, against a capture that has one. + """ + mid = build_mid(_device("bess-mid")) + + assert mid is not None + assert mid.islanding_state == "ON_GRID" + assert mid.grid_state == "UP" + assert mid.grid_forming_entity == "GRID" + assert mid.vendor_name == "Span" + assert mid.node_id == "bess-mid", "with no serial published, identity falls back to the device id" + assert mid.serial_number is None + + +def test_a_panel_with_no_mid_reports_none_rather_than_an_empty_device() -> None: + """Presence is `snapshot.mid is not None`, with nothing to infer. + + `has_bess` has to guess from `soe_percentage is not None` because the battery field + is always present; its own docstring records that only that one field is reliable. + A new optional device should not inherit that guessing game. + """ + assert build_mid(None) is None From 3c4eb40f17de4a4fa0b9e8e41e4b76d7870ec450 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:58:25 -0700 Subject: [PATCH 061/115] =?UTF-8?q?docs(models):=20panel.grid=5Fstate=20re?= =?UTF-8?q?nders=20no=20entity=20=E2=80=94=20correct=20the=20MID=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a644d30's docstring said `panel.grid_state` "continues to carry islanding state for the panel entity that has always shown it". There is no such entity: the integration never reads `grid_state`, checked across the whole component. What it does render is `dsm_state`, its `dsm_grid_state` alias, `current_run_config`, `dominant_power_source` and `grid_islandable` -- and on v1.0 four of those five are UNKNOWN or absent today. So adding the MID device does not by itself preserve anything a user sees; mapping the MID back into those fields is the work it enables. Also records the question the type does not settle: surfacing MID islanding state directly as well would show a user the same fact twice, which is not the benign cell of the absorb-or-surface policy. --- src/span_panel_api/models.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 1006fcc..34862ca 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -71,9 +71,17 @@ class SpanMidSnapshot: is why this is the benign cell of the absorb-or-surface policy: surfacing a new device cannot break an automation that never referenced it. - `panel.grid_state` continues to carry islanding state for the panel entity that has - always shown it. This does not replace that; it exposes the device the value comes - from, so a consumer can render the MID as hardware in its own right. + **Adding this device does not, on its own, fix anything a user sees.** The + integration renders no entity from `panel.grid_state` — checked, there is no such + sensor. What it does render is `dsm_state`, its `dsm_grid_state` alias, + `current_run_config`, `dominant_power_source` and `grid_islandable`, and on v1.0 + four of those five are currently `UNKNOWN` or absent. The MID is where their inputs + moved, so mapping it back into those fields is the work; this type is what makes + that possible, plus the option of rendering the MID as hardware in its own right. + + Which raises a design question this type does not settle: if the MID's islanding + state is also surfaced directly, a user sees the same fact twice. Duplicating an + existing state entity is not the benign cell of the absorb-or-surface policy. """ node_id: str From 012221e2e85ce6d68ee2ae626fa00196fb851e14 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:31:00 -0700 Subject: [PATCH 062/115] feat(schema_1): read the grid answers from the MID instead of deriving them Implements the three reads decided on 2026-08-08 and recorded in the delta document. Four of the five grid entities a user has were broken on v1.0; three of them are now restored, and the fourth is unblocked but unpublished. dsm_state UNKNOWN -> DSM_ON_GRID current_run_config UNKNOWN -> PANEL_ON_GRID grid_islandable None -> None (mapped; no producer publishes it) Same values flat produces on the same panel, which is the point -- these are existing entities whose ids and history must survive. **Read, do not derive.** Flat inferred both from `dominant-power-source` plus grid power because nothing stated them; the derivation was never ported, which is why they read UNKNOWN rather than a source having vanished. v1.0 states them on the MID, so the multi-signal heuristic is gone. `PANEL_BACKUP` versus `PANEL_OFF_GRID` gets strictly better: flat guessed from the dominant power source, v1.0 names the forming device and its class is recoverable from the tree. `resolve_islanding_state` implements the recorded precedence -- sensed from a ready MID; the user's `shed/asserted-islanding-state` when the MID is not ready; the `power-flows/grid` heuristic when there is no MID at all; unknown otherwise. Tier 2 is the case the assertion control exists for: comms lost, grid returns, user asserts so the BESS stops discharging. Tier 3 never answers OFF_GRID and never reports on-grid from a missing MID -- an earlier draft did, and a generator-fed island is the counterexample. `resolve_grid_islandable` maps `grid-forming/capable` over the BESS's inverter children and returns the disjunction, per the decision to relocate rather than derive from MID presence: a panel does not island, its DER does. It returns None rather than False when nothing publishes, so absence stays a gap instead of becoming a claim -- the integration declines to create the entity on None. No producer publishes it. The emitter models a BESS as one device with a MID child and no `inverter`, so `_NOT_EXERCISED_BY_SIMULATOR` is non-empty again for the first time since 2026-08-08, with a different cause than last time: not a config that failed to enable a device, but a device class the producer does not model. Vendored the `grid-forming` 0.1 catalog and pinned it, since the conformance suite correctly refused a read against a capability with no catalog. `optional_flag` is new because `flag` collapses "published false" and "not published" to False. Right for link state, wrong for a static capability. EXPECTED_DEGRADED is now empty, and that is a measurement. Falsified, all three, each by the specific failure its test names: dropping the assertion fallback, asserting on-grid from a missing MID, and reporting absence as "cannot island" each fail exactly one test. 603 passed, mypy clean. --- .../schema-1/spec/catalogs/grid-forming.json | 21 +++ .../src/span_panel_api_schema_1/const.py | 9 + .../src/span_panel_api_schema_1/panel.py | 160 +++++++++++++++++- .../src/span_panel_api_schema_1/snapshot.py | 36 +++- .../span_panel_api_schema_1/spec_lock.json | 1 + tests/test_auth_and_homie_helpers.py | 1 - tests/test_detection_auth.py | 1 - tests/test_protocol_models.py | 1 - tests/test_schema_migration_delta.py | 21 +-- tests/test_schema_one_conformance.py | 19 ++- tests/test_schema_one_panel.py | 127 +++++++++++++- tests/test_schema_one_snapshot.py | 26 ++- 12 files changed, 386 insertions(+), 37 deletions(-) create mode 100644 packages/schema-1/spec/catalogs/grid-forming.json diff --git a/packages/schema-1/spec/catalogs/grid-forming.json b/packages/schema-1/spec/catalogs/grid-forming.json new file mode 100644 index 0000000..0586814 --- /dev/null +++ b/packages/schema-1/spec/catalogs/grid-forming.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.grid-forming", + "version": "0.1", + "status": "DRAFT", + "date": "2026-07-11", + "properties": { + "capable": { + "datatype": "boolean", + "req": "MUST", + "description": "Static hardware capability: does this inverter support grid-forming operation at all? (when the capability is published)" + }, + "active": { + "datatype": "boolean", + "req": "SHOULD", + "description": "Current state: is this inverter actively grid-forming right now? When `false` and the inverter is energized, it is grid-following. (when `capable = true`)" + } + } +} diff --git a/packages/schema-1/src/span_panel_api_schema_1/const.py b/packages/schema-1/src/span_panel_api_schema_1/const.py index bfeea8b..6b0ce66 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/const.py +++ b/packages/schema-1/src/span_panel_api_schema_1/const.py @@ -16,6 +16,9 @@ TYPE_PV = "energy.ebus.device.pv" TYPE_EVSE = "energy.ebus.device.evse" TYPE_MID = "energy.ebus.device.mid" +# BESS model 0.14 decomposes a BESS into child roles; grid-forming belongs to the +# inverter, so "can this panel island" becomes "can any inverter here form a grid". +TYPE_INVERTER = "energy.ebus.device.inverter" TYPE_LUGS = "energy.ebus.device.lugs" # -- Capability nodes ------------------------------------------------------- @@ -30,6 +33,7 @@ NODE_PCS = "pcs" NODE_POWER_FLOWS = "power-flows" NODE_SHED = "shed" +NODE_GRID_FORMING = "grid-forming" NODE_SOC = "soc" NODE_STATUS = "status" NODE_SWITCH = "switch" @@ -51,6 +55,11 @@ # shed node PROP_ASSERTED_ISLANDING_STATE = "asserted-islanding-state" +# `energy.ebus.capability.grid-forming` 0.1: "Static hardware capability: does this +# inverter support grid-forming operation at all?" -- the same *kind* of statement +# flat's `grid-islandable` made, and a MUST on the capability. +PROP_CAPABLE = "capable" +PROP_GRID_FORMING_ENTITY = "grid-forming-entity" # Panel-level PROP_DATA_MODEL_VERSION = "data-model-version" diff --git a/packages/schema-1/src/span_panel_api_schema_1/panel.py b/packages/schema-1/src/span_panel_api_schema_1/panel.py index 4fcc5e4..6301192 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/panel.py +++ b/packages/schema-1/src/span_panel_api_schema_1/panel.py @@ -31,16 +31,21 @@ NODE_BREAKER, NODE_DOOR, NODE_GRID, + NODE_GRID_FORMING, NODE_INFO, NODE_METER, NODE_POWER_FLOWS, + NODE_SHED, NODE_STATUS, PANEL_SIZE_BY_MODEL, PROP_ACTIVE_POWER, + PROP_ASSERTED_ISLANDING_STATE, + PROP_CAPABLE, PROP_CLOUD_CONNECTION, PROP_ETHERNET, PROP_EXPORTED_ENERGY, PROP_FIRMWARE_VERSION, + PROP_GRID_FORMING_ENTITY, PROP_IMPORTED_ENERGY, PROP_MODEL, PROP_RATING, @@ -50,11 +55,14 @@ PROP_VOLTAGE_A, PROP_VOLTAGE_B, PROP_WIFI, + TYPE_BESS, UNKNOWN, UNMAPPED_TAB_PREFIX, ) if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from ebus_sdk.homie import DiscoveredDevice _LOGGER = logging.getLogger(__name__) @@ -98,6 +106,19 @@ def flag(device: DiscoveredDevice | None, node: str, prop: str) -> bool: return text(device, node, prop).strip().lower() == "true" +def optional_flag(device: DiscoveredDevice | None, node: str, prop: str) -> bool | None: + """`flag`, but distinguishing "published false" from "not published". + + `flag` collapses both to `False`, which is right for link-state properties where a + missing value means down. It is wrong for a static capability: reporting "cannot form + a grid" for a device that has not said would turn a gap into a claim. + """ + raw = text(device, node, prop).strip().lower() + if raw == "": + return None + return raw == "true" + + def panel_size_from_model(model: str) -> int: """Total breaker spaces for a panel model, or 0 when the model is unknown. @@ -280,12 +301,141 @@ def __init__( # stops being true. self.grid_state = text(mid, NODE_GRID, PROP_ISLANDING_STATE) or None - # Retired in v1.0 with no drop-in successor, and deliberately left - # None rather than substituted: `dominant-power-source` split into - # grid-forming-entity plus asserted-islanding-state, and - # `grid-islandable` was removed outright. Both are product decisions, - # tracked in the entity and config deltas write-up. + # `dominant-power-source` split into grid-forming-entity plus + # asserted-islanding-state — two controls on two devices, not one field + # moved — so there is no drop-in successor and this stays None until the + # decided replacement lands. self.dominant_power_source: str | None = None + # `grid_islandable` is answered by `resolve_grid_islandable` over the BESS's + # inverter children, not from the panel, so it is not a PanelFields concern. + # Kept as an attribute only so nothing that reads it breaks; the snapshot + # takes the resolver's answer. self.grid_islandable: bool | None = None # Not published by v1.0 firmware. self.wifi_ssid: str | None = None + + +# Matches `schema_0`'s epsilon so the no-MID heuristic answers identically on the two +# adapters — the tier exists precisely for panels where nothing authoritative is +# published, and disagreeing about the threshold would make it schema-dependent. +_GRID_POWER_EPSILON_W = 1.0 + +ISLANDING_ON_GRID = "ON_GRID" +ISLANDING_OFF_GRID = "OFF_GRID" +ASSERTION_NONE = "NONE" + + +def resolve_islanding_state(mid: DiscoveredDevice | None, panel: DiscoveredDevice) -> str | None: + """Islanding state by the recorded precedence, or `None` when nothing can say. + + | tier | condition | source | + | --- | --- | --- | + | 1 | MID `$state` is `ready` and `islanding-state` present | sensed | + | 2 | MID not `ready` | `shed/asserted-islanding-state`, when not `NONE` | + | 3 | no MID at all | `power-flows/grid` heuristic | + | 4 | none of the above | unknown | + + **Tier 2 is the reason the assertion control exists.** When comms to the BESS or MID + are lost and the grid returns, the user asserts the grid is up so the BESS stops + discharging. Declining to read it here would wire the control and then ignore it at + exactly the moment it matters. Nothing is hidden by doing so: the MID is a device, so + it goes *unavailable* in Home Assistant when it stops publishing, and the assertion is + itself visible as the control the user set. + + **Tier 3 never answers `OFF_GRID`, and never asserts on-grid from a missing MID.** An + earlier draft reasoned that no MID means no islanding authority means on-grid. That is + wrong: a missing MID means *SPAN* is not the islanding authority, and says nothing + about whether the site is islanded — a generator-fed island is the plain + counterexample. Grid power flowing is positive evidence of being on-grid; its absence + is not evidence of the opposite. + """ + if mid is not None: + if mid.state == "ready": + sensed = text(mid, NODE_GRID, PROP_ISLANDING_STATE) + if sensed: + return sensed + asserted = text(panel, NODE_SHED, PROP_ASSERTED_ISLANDING_STATE) + if asserted and asserted != ASSERTION_NONE: + return asserted + return None + + grid_power = number(panel, NODE_POWER_FLOWS, "grid") + if grid_power is not None and abs(grid_power) > _GRID_POWER_EPSILON_W: + return ISLANDING_ON_GRID + return None + + +def resolve_dsm_state(islanding: str | None) -> str: + """`dsm_state` in flat's vocabulary, read rather than derived. + + Flat inferred this from `bess/grid-state`, then `dominant-power-source`, then grid + power. v1.0 states it, so the heuristic tiers collapse into whatever + `resolve_islanding_state` could establish. Kept for entity stability: it adds nothing + over the MID's own value, and it is the entity a user already has. + """ + if islanding == ISLANDING_ON_GRID: + return "DSM_ON_GRID" + if islanding == ISLANDING_OFF_GRID: + return "DSM_OFF_GRID" + return UNKNOWN + + +def resolve_run_config( + mid: DiscoveredDevice | None, + islanding: str | None, + device_types: Mapping[str, str], +) -> str: + """`current_run_config`, from the grid-forming entity where one is published. + + | condition | result | + | --- | --- | + | `grid-forming-entity == "GRID"` | `PANEL_ON_GRID` | + | resolves to a device of class `bess` | `PANEL_BACKUP` | + | resolves to any other device | `PANEL_OFF_GRID` | + | absent, empty, or unresolvable | falls through below | + + This is the part that gets *better* than flat. Flat guessed `PANEL_BACKUP` versus + `PANEL_OFF_GRID` from `dominant-power-source`; v1.0 names the forming device and its + class is recoverable from the tree, so the distinction becomes authoritative. + + Falling through, the answer degrades honestly rather than guessing: an on-grid + islanding answer still gives `PANEL_ON_GRID`, but off-grid cannot be split into + backup versus off-grid without knowing what is forming the grid, so it reports + unknown rather than picking one. + """ + forming = text(mid, NODE_GRID, PROP_GRID_FORMING_ENTITY).strip() + if forming: + if forming.upper() == "GRID": + return "PANEL_ON_GRID" + resolved = device_types.get(forming) + if resolved == TYPE_BESS: + return "PANEL_BACKUP" + if resolved is not None: + return "PANEL_OFF_GRID" + + if islanding == ISLANDING_ON_GRID: + return "PANEL_ON_GRID" + return UNKNOWN + + +def resolve_grid_islandable(inverters: Sequence[DiscoveredDevice]) -> bool | None: + """Whether any inverter can form a grid — flat's `grid-islandable`, relocated. + + `grid-forming/capable` is *"Static hardware capability: does this inverter support + grid-forming operation at all?"*, the same kind of permanent statement flat made with + *"Capable of operating with power while disconnected from the grid."* BESS model 0.14 + puts it on the `inverter` child, so the panel-level answer is the disjunction: a panel + does not island, its DER does, and flat expressed a property of the DER as a property + of the enclosure. + + `None`, not `False`, when nothing publishes it. Absence means unknown — reporting + "cannot island" for a panel that simply has not told us would turn a gap into a claim, + and the integration declines to create the entity on `None`, which is the honest + outcome. No producer publishes this today: the emitter does not model the BESS child + roles, so this reads `None` against every capture we have. + """ + answers = [optional_flag(inverter, NODE_GRID_FORMING, PROP_CAPABLE) for inverter in inverters] + known = [answer for answer in answers if answer is not None] + if not known: + return None + return any(known) diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index 8ab387a..3b695cf 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -20,13 +20,23 @@ TYPE_BESS, TYPE_CIRCUIT, TYPE_EVSE, + TYPE_INVERTER, TYPE_LUGS, TYPE_MID, TYPE_PV, - UNKNOWN, ) from span_panel_api_schema_1.devices import build_battery, build_evse, build_mid, build_pv, feed_circuit_ids -from span_panel_api_schema_1.panel import PanelFields, build_unmapped_tabs, find_lugs, panel_size_from_model, text +from span_panel_api_schema_1.panel import ( + PanelFields, + build_unmapped_tabs, + find_lugs, + panel_size_from_model, + resolve_dsm_state, + resolve_grid_islandable, + resolve_islanding_state, + resolve_run_config, + text, +) if TYPE_CHECKING: from collections.abc import Sequence @@ -110,6 +120,14 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re # Owners are every device that can claim a DER through a `connection` node. owners = [*roles.lugs, *roles.circuits, panel] + # Grid answers are read from the MID rather than derived, per the recorded + # decision. `device_types` resolves `grid-forming-entity` to a device class, which + # is what makes PANEL_BACKUP distinguishable from PANEL_OFF_GRID authoritatively + # instead of guessed from a power source the way flat had to. + device_types = {device.device_id: device_type(device) for device in children} + inverters = [device for device in children if device_type(device) == TYPE_INVERTER] + islanding = resolve_islanding_state(roles.mid, panel) + return SpanPanelSnapshot( serial_number=fields.serial_number, firmware_version=fields.firmware_version, @@ -120,12 +138,12 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re main_meter_energy_produced_wh=fields.main_meter_energy_produced_wh, feedthrough_energy_consumed_wh=fields.feedthrough_energy_consumed_wh, feedthrough_energy_produced_wh=fields.feedthrough_energy_produced_wh, - # Both are v1 fields the flat adapter derives from multiple v2 signals. - # Left UNKNOWN here rather than reproducing that heuristic against a - # schema whose inputs moved: `dominant-power-source` and - # `grid-islandable` — two of its three inputs — no longer exist. - dsm_state=UNKNOWN, - current_run_config=UNKNOWN, + # Read, not derived. Flat had to infer both from `dominant-power-source` + # plus grid power because nothing stated them; v1.0 states them on the MID, + # so the multi-signal heuristic is gone and only the no-MID tier remains a + # heuristic. The user-visible value set is unchanged. + dsm_state=resolve_dsm_state(islanding), + current_run_config=resolve_run_config(roles.mid, islanding, device_types), door_state=fields.door_state, # The panel has no proximity sensor property; the flat adapter reports # authenticated-and-ready, and the same holds here. @@ -137,7 +155,7 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re panel_size=panel_size, dominant_power_source=fields.dominant_power_source, grid_state=fields.grid_state, - grid_islandable=fields.grid_islandable, + grid_islandable=resolve_grid_islandable(inverters), l1_voltage=fields.l1_voltage, l2_voltage=fields.l2_voltage, main_breaker_rating_a=fields.main_breaker_rating_a, diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json index 6962f59..5c1053d 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -28,6 +28,7 @@ "connection": "0.1", "door": "0.1", "grid": "0.1", + "grid-forming": "0.1", "info": "0.2", "load-shed": "0.3", "meter": "0.2", diff --git a/tests/test_auth_and_homie_helpers.py b/tests/test_auth_and_homie_helpers.py index dc39277..5c5c0cd 100644 --- a/tests/test_auth_and_homie_helpers.py +++ b/tests/test_auth_and_homie_helpers.py @@ -13,7 +13,6 @@ from span_panel_api.auth import _int, download_ca_cert, get_homie_schema from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelTimeoutError - # --------------------------------------------------------------------------- # auth._int edge cases (lines 29-31) # --------------------------------------------------------------------------- diff --git a/tests/test_detection_auth.py b/tests/test_detection_auth.py index 5ff9c07..0aef2a5 100644 --- a/tests/test_detection_auth.py +++ b/tests/test_detection_auth.py @@ -29,7 +29,6 @@ register_v2, ) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tests/test_protocol_models.py b/tests/test_protocol_models.py index fced6c3..6fd1131 100644 --- a/tests/test_protocol_models.py +++ b/tests/test_protocol_models.py @@ -10,7 +10,6 @@ SpanPanelSnapshot, ) - # --------------------------------------------------------------------------- # Helpers: snapshot factory functions # --------------------------------------------------------------------------- diff --git a/tests/test_schema_migration_delta.py b/tests/test_schema_migration_delta.py index dabc290..5c9c9b5 100644 --- a/tests/test_schema_migration_delta.py +++ b/tests/test_schema_migration_delta.py @@ -127,16 +127,17 @@ ), } -EXPECTED_DEGRADED: dict[str, str] = { - "panel.dsm_state": ( - "reads UNKNOWN on v1.0 where flat answered. Its authoritative input survives as " - "the MID's grid/islanding-state and its fallback as grid power, so the UNKNOWN is " - "more conservative than the data requires — reconstruction is an open item" - ), - "panel.current_run_config": ( - "reads UNKNOWN on v1.0 where flat answered; no v1.0 source identified yet. Severity 2 in the delta document" - ), -} +EXPECTED_DEGRADED: dict[str, str] = {} +"""Empty as of 2026-08-10, and that is a measurement rather than a default. + +Both members were `panel.dsm_state` and `panel.current_run_config`, reading UNKNOWN on +v1.0 where flat answered. Neither was a source that vanished: flat *derives* them, and +the derivation was simply never ported. v1.0 states the answer on the MID, so they are +now read rather than derived and carry the same values a user has today — +`DSM_ON_GRID` / `PANEL_ON_GRID` on the tracked capture. + +Zero is the state worth defending, so the dict stays and the check below holds it. +""" """Fields that survive the migration as entities but stop carrying an answer. Worse for a user than an orphan, because an orphan goes stale and is noticeable diff --git a/tests/test_schema_one_conformance.py b/tests/test_schema_one_conformance.py index 5b4b1b9..6e9d54d 100644 --- a/tests/test_schema_one_conformance.py +++ b/tests/test_schema_one_conformance.py @@ -213,9 +213,24 @@ def _simulator_declared() -> set[tuple[str, str]]: # parser reads is now exercised by the capture it is developed against. # # The mechanism stays for the next gap. An empty dict is the honest state, and it -# is load-bearing: the coverage check now holds every mapping with nothing +# is load-bearing: the coverage check holds every other mapping with nothing # excused, so a future producer regression fails rather than lands here. -_NOT_EXERCISED_BY_SIMULATOR: dict[tuple[str, str], str] = {} +# +# Non-empty again as of 2026-08-10, with one entry and a different cause than the +# last: not a config that failed to enable a device, but a device class the +# producer does not model at all. +_NOT_EXERCISED_BY_SIMULATOR: dict[tuple[str, str], str] = { + ("grid-forming", "capable"): ( + "BESS model 0.14 decomposes a BESS into `battery` / `inverter` / `mid` child " + "roles and puts grid-forming on the inverter. The emitter models the BESS as a " + "single device with no children other than the MID, so no inverter exists to " + "carry the capability and nothing publishes it. Read anyway, because it is the " + "decided successor to flat's `grid_islandable` and the mapping is unit-tested " + "against a synthetic inverter -- but with no producer evidence, which is what " + "this entry records. `resolve_grid_islandable` returns None rather than False " + "on absence, so the gap surfaces as an uncreated entity rather than a claim." + ), +} # --------------------------------------------------------------------------- diff --git a/tests/test_schema_one_panel.py b/tests/test_schema_one_panel.py index 832a2b7..c3f9f21 100644 --- a/tests/test_schema_one_panel.py +++ b/tests/test_schema_one_panel.py @@ -13,13 +13,16 @@ from ebus_sdk.homie import DiscoveredDevice -from span_panel_api_schema_1.const import NODE_GRID +from span_panel_api_schema_1.const import NODE_GRID, TYPE_BESS from span_panel_api_schema_1.panel import ( PanelFields, build_unmapped_tabs, find_lugs, panel_model_drift, panel_size_from_model, + resolve_grid_islandable, + resolve_islanding_state, + resolve_run_config, ) _TREE = json.loads((Path(__file__).parent / "fixtures" / "parent_child_tree.json").read_text(encoding="utf-8")) @@ -264,3 +267,125 @@ def test_an_unsizable_panel_yields_no_unmapped_positions() -> None: """Better nothing than a fabricated set: size 0 is what an unknown model reports, and inventing positions would create phantom entities.""" assert build_unmapped_tabs(panel_size=0, occupied={1}) == {} + + +# --------------------------------------------------------------------------- +# Grid answers: read, not derived — the 2026-08-10 decision +# --------------------------------------------------------------------------- + + +def _synthetic(device_id: str, state: str = "ready", **props: str) -> DiscoveredDevice: + """A device built from nothing, for the cases no capture contains. + + The tracked producer models a BESS as one device with a MID child and no + `inverter`, so `grid-forming/capable` has nowhere to live in any fixture. That is + recorded in `_NOT_EXERCISED_BY_SIMULATOR`; this is what stops the mapping being + merely untested as well as unexercised. + """ + device = DiscoveredDevice(device_id, "ebus") + device.update_state(state) + for path, value in props.items(): + # `node__prop_name` -> node/prop-name, since the wire spells both with hyphens + # and a Python keyword cannot. + node, _, prop = path.partition("__") + device.update_property(node.replace("_", "-"), prop.replace("_", "-"), value) + return device + + +def test_islanding_is_sensed_when_the_mid_is_ready() -> None: + """Tier 1. The MID is the islanding authority, so its answer wins outright.""" + mid = _synthetic("mid", grid__islanding_state="OFF_GRID") + panel = _synthetic(PANEL, shed__asserted_islanding_state="ON_GRID") + + assert resolve_islanding_state(mid, panel) == "OFF_GRID", "a ready MID outranks the user's assertion" + + +def test_a_stale_mid_falls_back_to_the_users_assertion() -> None: + """Tier 2, and the case the assertion control exists for. + + When comms to the BESS or MID are lost and the grid returns, the user asserts the + grid is up so the BESS stops discharging. Declining to read it would wire the + control and then ignore it at exactly the moment it matters. + """ + mid = _synthetic("mid", state="lost", grid__islanding_state="OFF_GRID") + panel = _synthetic(PANEL, shed__asserted_islanding_state="ON_GRID") + + assert resolve_islanding_state(mid, panel) == "ON_GRID" + + +def test_a_stale_mid_with_no_assertion_is_unknown_not_guessed() -> None: + """Tier 4. `NONE` is the assertion's idle value, not an answer.""" + mid = _synthetic("mid", state="lost", grid__islanding_state="ON_GRID") + panel = _synthetic(PANEL, shed__asserted_islanding_state="NONE") + + assert resolve_islanding_state(mid, panel) is None + + +def test_no_mid_reads_grid_power_and_never_asserts_off_grid() -> None: + """Tier 3, and the error worth keeping a test on. + + An earlier draft reasoned that no MID means no islanding authority means on-grid. + A missing MID means *SPAN* is not the authority and says nothing about whether the + site is islanded — a generator-fed island is the counterexample. Grid power flowing + is positive evidence of being on-grid; its absence is not evidence of the opposite. + """ + assert resolve_islanding_state(None, _synthetic(PANEL, power_flows__grid="2400.0")) == "ON_GRID" + assert resolve_islanding_state(None, _synthetic(PANEL, power_flows__grid="0.0")) is None + assert resolve_islanding_state(None, _synthetic(PANEL)) is None + + +def test_run_config_names_the_forming_device_rather_than_guessing_it() -> None: + """The part that gets better than flat. + + Flat guessed `PANEL_BACKUP` versus `PANEL_OFF_GRID` from `dominant-power-source`. + v1.0 names the forming device, and its class is recoverable from the tree. + """ + types = {"bess-1": TYPE_BESS, "gen-1": "energy.ebus.device.generator"} + + on_grid = _synthetic("mid", grid__grid_forming_entity="GRID") + backup = _synthetic("mid", grid__grid_forming_entity="bess-1") + off_grid = _synthetic("mid", grid__grid_forming_entity="gen-1") + + assert resolve_run_config(on_grid, "ON_GRID", types) == "PANEL_ON_GRID" + assert resolve_run_config(backup, "OFF_GRID", types) == "PANEL_BACKUP" + assert resolve_run_config(off_grid, "OFF_GRID", types) == "PANEL_OFF_GRID" + + +def test_run_config_degrades_honestly_when_the_forming_entity_is_unusable() -> None: + """Unresolvable is not an excuse to pick one. + + Without knowing what is forming the grid, off-grid cannot be split into backup + versus off-grid, so it reports unknown. On-grid still answers, because the islanding + tier already established it. + """ + unresolvable = _synthetic("mid", grid__grid_forming_entity="a-device-not-in-this-tree") + + assert resolve_run_config(unresolvable, "OFF_GRID", {}) == "UNKNOWN" + assert resolve_run_config(unresolvable, "ON_GRID", {}) == "PANEL_ON_GRID" + assert resolve_run_config(None, None, {}) == "UNKNOWN" + + +def test_grid_islandable_is_the_disjunction_over_inverters() -> None: + """Flat's `grid_islandable`, relocated to where the capability actually lives. + + A panel does not island, its DER does; flat expressed a property of the DER as a + property of the enclosure. BESS model 0.14 puts grid-forming on the `inverter` + child, so the panel-level answer is "can any inverter here form a grid". + """ + capable = _synthetic("inv-1", grid_forming__capable="true") + incapable = _synthetic("inv-2", grid_forming__capable="false") + + assert resolve_grid_islandable([capable]) is True + assert resolve_grid_islandable([incapable]) is False + assert resolve_grid_islandable([incapable, capable]) is True, "one grid-forming inverter is enough" + + +def test_an_inverter_that_says_nothing_is_unknown_not_incapable() -> None: + """`None`, not `False`. Absence means unknown. + + Reporting "cannot island" for a panel that has not told us turns a gap into a claim, + and the integration declines to create the entity on `None` — an absent entity is + the honest outcome, a confidently wrong one is not. + """ + assert resolve_grid_islandable([_synthetic("inv-1")]) is None + assert resolve_grid_islandable([]) is None diff --git a/tests/test_schema_one_snapshot.py b/tests/test_schema_one_snapshot.py index ed110c6..9abff6e 100644 --- a/tests/test_schema_one_snapshot.py +++ b/tests/test_schema_one_snapshot.py @@ -107,13 +107,25 @@ def test_panel_and_lugs_values_reach_the_snapshot(snapshot: SpanPanelSnapshot) - assert snapshot.l1_voltage == 120.0 -def test_derived_v1_fields_are_unknown_rather_than_reconstructed(snapshot: SpanPanelSnapshot) -> None: - """The flat adapter derives these from several v2 signals, two of which - (`dominant-power-source`, `grid-islandable`) no longer exist. Reproducing - the heuristic against missing inputs would produce a confident wrong - answer.""" - assert snapshot.dsm_state == "UNKNOWN" - assert snapshot.current_run_config == "UNKNOWN" +def test_the_grid_answers_are_read_from_the_mid_not_derived(snapshot: SpanPanelSnapshot) -> None: + """Both entities keep the values a user has today, by reading instead of guessing. + + Flat inferred these from `dominant-power-source` plus grid power because nothing + stated them. v1.0 states them on the MID, so the multi-signal heuristic is gone and + the answer is authoritative -- while the user-visible vocabulary is unchanged, which + is the whole point: `dsm_state` and `current_run_config` are existing entities whose + history must survive the migration. + + This asserted `UNKNOWN` for both until 2026-08-10, on the reasoning that two of the + heuristic's three inputs no longer exist. True of the *inputs*, wrong as a conclusion: + v1.0 removed the need to infer rather than the ability to answer. + + `PANEL_BACKUP` versus `PANEL_OFF_GRID` gets strictly better than flat here — flat + guessed it from the dominant power source, v1.0 names the forming device and its + class is recoverable from the tree. + """ + assert snapshot.dsm_state == "DSM_ON_GRID" + assert snapshot.current_run_config == "PANEL_ON_GRID" def test_an_unsizable_panel_yields_no_unmapped_positions() -> None: From 2268db1f5a8ae25175a1b8ff9d7c1da1e5291889 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:04:52 -0700 Subject: [PATCH 063/115] feat!: normalise DER identity onto v1.0's vocabulary BREAKING. `model` is the human designation and `part_number` the SKU, on `battery`, `evse` and `pv` alike. `product_name` is retired on all three. Flat is the inconsistent side, not v1.0: it puts the SKU in `bess/model` and in `evse/part-number` -- the same concept under two names -- and gives PV neither. v1.0 normalises all three, so the "semantic swap" the delta document recorded exists only on the BESS, and only because flat was irregular there. `schema_1` used to cross over (`info/part-number` -> `battery.model`) to hold each entity's displayed meaning still. It worked, and it permanently encoded flat's irregularity in the snapshot, so every future reader had to be told why `battery.model` was not a model. `schema_0` now translates flat into the normalised shape instead of mirroring flat's names: `bess/model` -> `part_number`, `product-name` -> `model`. Measured, and this is the point of the change: every EVSE identity field now reads identically on both adapters -- model, part_number, vendor_name, serial_number, software_version -- so for that device class identity stops being a migration delta at all. The EVSE is the only class that can demonstrate it, because the frozen flat simulator publishes full identity for it, none for the BESS and `vendor-name` alone for PV. Those gaps are the simulator's, recorded in PROVISIONAL_DER. **`battery.model` changes value for existing flat users at this upgrade**, gaining the designation where it carried the SKU. Metadata, nothing automates on it, and the better string is the one it gains. The trade is deliberate: a change we schedule in a library release beats the same change arriving unplanned during a firmware upgrade whose timing is not ours. `pv.model` moves from PROVISIONAL_DER to ATTESTED_AGAINST_FIRMWARE with its rename -- real flat firmware sends `pv/product-name`, measured in the live differential, so it is an identity rather than an addition. And the whole provisional set now points toward identity rather than semantic change: with both adapters speaking the same vocabulary, a flat capture carrying BESS identity would reclassify all three at once. Versions bumped to 3.0.0b3 / 1.0.0b3 / 0.1.0b3. Consumers reading `product_name` must move to `model` in the same release; the Home Assistant integration builds its device-registry model from it and device cards would otherwise go blank. Falsified: making `schema_0` mirror flat's names instead of translating fails `test_battery_metadata`. Worth recording that the first attempt at that falsification lied -- the mutation and the restore were byte-identical in length and landed in the same mtime second, so Python reused a stale .pyc and the "failure" persisted after the restore. Clearing __pycache__ is now part of the loop. 604 passed, mypy clean. --- CHANGELOG.md | 30 ++++++ packages/schema-0/pyproject.toml | 2 +- .../src/span_panel_api_schema_0/consumer.py | 12 ++- .../span_panel_api_schema_0/field_metadata.py | 12 ++- packages/schema-1/pyproject.toml | 2 +- .../src/span_panel_api_schema_1/devices.py | 12 ++- .../span_panel_api_schema_1/field_metadata.py | 6 +- pyproject.toml | 2 +- src/span_panel_api/models.py | 10 +- tests/test_mqtt_homie.py | 14 +-- tests/test_schema_migration_delta.py | 91 ++++++++++++------- tests/test_schema_one_devices.py | 27 ++++-- tests/test_schema_one_snapshot.py | 2 +- uv.lock | 6 +- 14 files changed, 154 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2248897..5b432d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0b3] - 08/2026 + +Pre-release. Normalises DER identity onto v1.0's vocabulary, and stops deriving the grid answers that v1.0 states outright. + +### Changed + +- **BREAKING — DER identity speaks v1.0's vocabulary on every device class.** `model` is the human designation and `part_number` the SKU, on `battery`, `evse` and `pv` alike. `product_name` is retired on all three. Flat is the inconsistent side, not v1.0: + it puts the SKU in `bess/model` and in `evse/part-number`, the same concept under two names, and gives PV neither. `schema_1` used to cross over (`info/part-number` → `battery.model`) to hold each entity's displayed meaning still, which worked and + permanently encoded flat's irregularity in the snapshot. `schema_0` now translates flat into the normalised shape instead of mirroring it. Measured: every EVSE identity field reads identically on both adapters, so for that device class identity stops + being a migration delta at all. **`battery.model` changes value for existing flat users at this upgrade** — it gains the designation where it carried the SKU. That is the deliberate trade: a change we schedule in a library release beats the same change + arriving unplanned during a firmware upgrade a user did not choose the timing of. +- **Consumers reading `product_name` must move to `model` in the same release.** The Home Assistant integration builds its device-registry model from it; left unchanged, device cards go blank. + +### Added + +- **`SpanMidSnapshot`, and `SpanPanelSnapshot.mid`.** v1.0 publishes a Microgrid Interconnect Device and the enclosure model puts the `grid` capability on it rather than on the enclosure, so islanding state, grid state and the grid-forming entity live + there. Previously one of its five properties was read and the device discarded. Purely additive: no flat panel publishes a MID, so nothing existing changes. Presence is `snapshot.mid is not None` rather than a sentinel field, and identity is + `info/serial-number` rather than the Homie device id, which the proxy model warns is not stable across a proxy-to-native transition. + +### Fixed + +- **`dsm_state` and `current_run_config` are read from the MID instead of reading `UNKNOWN`.** Both are existing entities that had degraded on v1.0 — not because a source vanished, but because `schema_0` _derives_ them and the derivation was never ported. + v1.0 states the answer, so the multi-signal heuristic is gone: sensed from a ready MID, falling back to the user's `shed/asserted-islanding-state` when it is not ready, then to a `power-flows/grid` heuristic when there is no MID at all, and unknown + otherwise. A missing MID never reports on-grid — it means SPAN is not the islanding authority, not that the site is on grid, and a generator-fed island is the counterexample. `PANEL_BACKUP` versus `PANEL_OFF_GRID` becomes authoritative rather than + guessed, because v1.0 names the forming device and its class is recoverable from the tree. +- **`grid_islandable` is mapped to `grid-forming/capable`** over the BESS's inverter children, as the disjunction — a panel does not island, its DER does, and flat expressed a property of the DER as a property of the enclosure. It returns `None` rather + than `False` when nothing publishes it, so absence stays a gap instead of becoming a claim. No producer publishes it today, which is recorded rather than worked around. +- **EVSE identity survives the migration.** The snapshot key and `node_id` — which a consumer builds a `unique_id` and a device-registry identifier from — were the v1.0 device id on `schema_1` and firmware's node name on `schema_0`, so every charger would + have orphaned and reappeared as a duplicate. Both are the Drive's serial now, which is what real flat firmware keys by. + ## [3.0.0b2] - 08/2026 Pre-release. Releases the reshaped `SchemaAdapter` protocol that `3.0.0b1` predates, and makes the mismatch between the two detectable rather than fatal at construction. diff --git a/packages/schema-0/pyproject.toml b/packages/schema-0/pyproject.toml index c6e44e6..376b32f 100644 --- a/packages/schema-0/pyproject.toml +++ b/packages/schema-0/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api-schema-0" -version = "1.0.0b2" +version = "1.0.0b3" description = "Flat-schema (data-model-version absent) parser for span-panel-api" authors = [ {name = "SpanPanel"} diff --git a/packages/schema-0/src/span_panel_api_schema_0/consumer.py b/packages/schema-0/src/span_panel_api_schema_0/consumer.py index ef716f4..f96485e 100644 --- a/packages/schema-0/src/span_panel_api_schema_0/consumer.py +++ b/packages/schema-0/src/span_panel_api_schema_0/consumer.py @@ -322,8 +322,12 @@ def _build_battery(self) -> SpanBatterySnapshot: soe_percentage=_parse_float(soc_str) if soc_str else None, soe_kwh=_parse_float(soe_str) if soe_str else None, vendor_name=vn if vn else None, - product_name=pn if pn else None, - model=mdl if mdl else None, + # Flat is the irregular side: it puts the SKU in `model` on the BESS and in + # `part-number` on the EVSE, for the same concept. The snapshot speaks v1.0's + # vocabulary now, so translate rather than mirror -- `product-name` is the + # designation and flat's `bess/model` is the SKU. + model=pn if pn else None, + part_number=mdl if mdl else None, serial_number=sn if sn else None, software_version=sw if sw else None, nameplate_capacity_kwh=_parse_float(nc) if nc else None, @@ -344,7 +348,7 @@ def _build_pv(self) -> SpanPVSnapshot: return SpanPVSnapshot( vendor_name=vn if vn else None, - product_name=pn if pn else None, + model=pn if pn else None, nameplate_capacity_w=_parse_float(nc) if nc else None, feed_circuit_id=normalize_circuit_id(feed) if feed else None, relative_position=rel_pos.upper() if rel_pos else None, @@ -367,7 +371,7 @@ def _build_evse_devices(self) -> dict[str, SpanEvseSnapshot]: lock_state=self._acc.get_prop(node_id, "lock-state") or "UNKNOWN", advertised_current_a=_parse_float(adv) if adv else None, vendor_name=self._acc.get_prop(node_id, "vendor-name") or None, - product_name=self._acc.get_prop(node_id, "product-name") or None, + model=self._acc.get_prop(node_id, "product-name") or None, part_number=self._acc.get_prop(node_id, "part-number") or None, serial_number=self._acc.get_prop(node_id, "serial-number") or None, software_version=self._acc.get_prop(node_id, "software-version") or None, diff --git a/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py b/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py index 6da5388..418c9d2 100644 --- a/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py +++ b/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py @@ -82,8 +82,12 @@ (TYPE_BESS, "soc", "battery.soe_percentage"), (TYPE_BESS, "soe", "battery.soe_kwh"), (TYPE_BESS, "vendor-name", "battery.vendor_name"), - (TYPE_BESS, "product-name", "battery.product_name"), - (TYPE_BESS, "model", "battery.model"), + # Flat's irregularity, translated rather than mirrored: it puts the designation in + # `product-name` and the SKU in `model` on the BESS, where the EVSE puts the SKU in + # `part-number`. The snapshot speaks v1.0's vocabulary, so both land on the field + # that matches the concept. + (TYPE_BESS, "product-name", "battery.model"), + (TYPE_BESS, "model", "battery.part_number"), (TYPE_BESS, "serial-number", "battery.serial_number"), (TYPE_BESS, "software-version", "battery.software_version"), (TYPE_BESS, "nameplate-capacity", "battery.nameplate_capacity_kwh"), @@ -91,7 +95,7 @@ (TYPE_BESS, "grid-state", "panel.grid_state"), # --- PV → pv.* ----------------------------------------------------------- (TYPE_PV, "vendor-name", "pv.vendor_name"), - (TYPE_PV, "product-name", "pv.product_name"), + (TYPE_PV, "product-name", "pv.model"), (TYPE_PV, "nameplate-capacity", "pv.nameplate_capacity_w"), (TYPE_PV, "feed", "pv.feed_circuit_id"), (TYPE_PV, "relative-position", "pv.relative_position"), # IN_PANEL | UPSTREAM | DOWNSTREAM @@ -100,7 +104,7 @@ (TYPE_EVSE, "lock-state", "evse.lock_state"), (TYPE_EVSE, "advertised-current", "evse.advertised_current_a"), (TYPE_EVSE, "vendor-name", "evse.vendor_name"), - (TYPE_EVSE, "product-name", "evse.product_name"), + (TYPE_EVSE, "product-name", "evse.model"), (TYPE_EVSE, "part-number", "evse.part_number"), (TYPE_EVSE, "serial-number", "evse.serial_number"), (TYPE_EVSE, "software-version", "evse.software_version"), diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index 53d48d3..e88edab 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api-schema-1" -version = "0.1.0b2" +version = "0.1.0b3" description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api" authors = [ {name = "SpanPanel"} diff --git a/packages/schema-1/src/span_panel_api_schema_1/devices.py b/packages/schema-1/src/span_panel_api_schema_1/devices.py index a264ad6..1525404 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/devices.py +++ b/packages/schema-1/src/span_panel_api_schema_1/devices.py @@ -107,9 +107,11 @@ def build_battery(bess: DiscoveredDevice | None, owners: list[DiscoveredDevice]) soe_percentage=number(bess, NODE_SOC, PROP_SOC), soe_kwh=number(bess, NODE_SOC, PROP_SOE), vendor_name=_optional(text(bess, NODE_INFO, PROP_VENDOR_NAME)), - # The swap: designation to product_name, SKU to model. - product_name=_optional(text(bess, NODE_INFO, PROP_MODEL)), - model=_optional(text(bess, NODE_INFO, PROP_PART_NUMBER)), + # No crossover any more. The snapshot speaks v1.0's vocabulary, so + # `info/model` is the designation and `info/part-number` is the SKU, on every + # device class. `schema_0` translates flat's irregular naming into this shape. + model=_optional(text(bess, NODE_INFO, PROP_MODEL)), + part_number=_optional(text(bess, NODE_INFO, PROP_PART_NUMBER)), serial_number=_optional(text(bess, NODE_INFO, PROP_SERIAL_NUMBER)), software_version=_optional(text(bess, NODE_INFO, PROP_FIRMWARE_VERSION)), nameplate_capacity_kwh=number(bess, NODE_INFO, PROP_NAMEPLATE_CAPACITY), @@ -125,7 +127,7 @@ def build_pv(pv: DiscoveredDevice | None, feeds: dict[str, str]) -> SpanPVSnapsh return SpanPVSnapshot( vendor_name=_optional(text(pv, NODE_INFO, PROP_VENDOR_NAME)), - product_name=_optional(text(pv, NODE_INFO, PROP_MODEL)), + model=_optional(text(pv, NODE_INFO, PROP_MODEL)), nameplate_capacity_w=number(pv, NODE_INFO, PROP_NOMINAL_POWER), feed_circuit_id=feeds.get(pv.device_id), # `relative-position` is retired in v1.0 and the guide is explicit that @@ -150,7 +152,7 @@ def build_evse(evse: DiscoveredDevice, feeds: dict[str, str], *, node_id: str) - lock_state=text(evse, NODE_SWITCH, PROP_LOCK_STATE, UNKNOWN), advertised_current_a=number(evse, NODE_METER, PROP_ADVERTISED_CURRENT), vendor_name=_optional(text(evse, NODE_INFO, PROP_VENDOR_NAME)), - product_name=_optional(text(evse, NODE_INFO, PROP_MODEL)), + model=_optional(text(evse, NODE_INFO, PROP_MODEL)), part_number=_optional(text(evse, NODE_INFO, PROP_PART_NUMBER)), serial_number=_optional(text(evse, NODE_INFO, PROP_SERIAL_NUMBER)), software_version=_optional(text(evse, NODE_INFO, PROP_FIRMWARE_VERSION)), diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index 025cac9..6fdfc0a 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -92,13 +92,13 @@ (TYPE_BESS, NODE_SOC, "soc", "battery.soe_percentage"), (TYPE_BESS, NODE_SOC, "soe", "battery.soe_kwh"), (TYPE_BESS, NODE_INFO, "vendor-name", "battery.vendor_name"), - (TYPE_BESS, NODE_INFO, "model", "battery.product_name"), - (TYPE_BESS, NODE_INFO, "part-number", "battery.model"), + (TYPE_BESS, NODE_INFO, "model", "battery.model"), + (TYPE_BESS, NODE_INFO, "part-number", "battery.part_number"), (TYPE_BESS, NODE_INFO, "serial-number", "battery.serial_number"), (TYPE_BESS, NODE_INFO, "nameplate-capacity", "battery.nameplate_capacity_kwh"), # --- PV ------------------------------------------------------------------ (TYPE_PV, NODE_INFO, "vendor-name", "pv.vendor_name"), - (TYPE_PV, NODE_INFO, "model", "pv.product_name"), + (TYPE_PV, NODE_INFO, "model", "pv.model"), (TYPE_PV, NODE_INFO, "nominal-power", "pv.nameplate_capacity_w"), # --- EVSE ---------------------------------------------------------------- (TYPE_EVSE, NODE_STATUS, "status", "evse.status"), diff --git a/pyproject.toml b/pyproject.toml index a99b380..331399a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "3.0.0b2" +version = "3.0.0b3" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 34862ca..b1f127b 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -51,7 +51,7 @@ class SpanPVSnapshot: """PV inverter metadata — populated only when a PV node is commissioned.""" vendor_name: str | None = None # pv/vendor-name - product_name: str | None = None # pv/product-name + model: str | None = None # human designation (v1.0 info/model; flat pv/product-name) nameplate_capacity_w: float | None = None # pv/nameplate-capacity (W) feed_circuit_id: str | None = None # pv/feed (normalized circuit ID) relative_position: str | None = None # pv/relative-position (IN_PANEL | UPSTREAM | DOWNSTREAM) @@ -115,8 +115,8 @@ class SpanEvseSnapshot: advertised_current_a: float | None = None # Amps offered to EV # Device metadata — flows into HA DeviceInfo, not separate entities vendor_name: str | None = None - product_name: str | None = None - part_number: str | None = None + model: str | None = None # human designation (v1.0 info/model; flat evse/product-name) + part_number: str | None = None # SKU serial_number: str | None = None software_version: str | None = None @@ -132,8 +132,8 @@ class SpanBatterySnapshot: # BESS metadata vendor_name: str | None = None # bess/vendor-name - product_name: str | None = None # bess/product-name - model: str | None = None # bess/model + model: str | None = None # human designation (v1.0 info/model; flat bess/product-name) + part_number: str | None = None # SKU (v1.0 info/part-number; flat bess/model) serial_number: str | None = None # bess/serial-number software_version: str | None = None # bess/software-version nameplate_capacity_kwh: float | None = None # bess/nameplate-capacity (kWh) diff --git a/tests/test_mqtt_homie.py b/tests/test_mqtt_homie.py index 19a163f..9a109ee 100644 --- a/tests/test_mqtt_homie.py +++ b/tests/test_mqtt_homie.py @@ -635,7 +635,7 @@ def test_battery_metadata(self): snapshot = consumer.build_snapshot() assert snapshot.battery.vendor_name == "Tesla" - assert snapshot.battery.product_name == "Powerwall 3" + assert snapshot.battery.model == "Powerwall 3" # flat product-name is the designation assert snapshot.battery.nameplate_capacity_kwh == 13.5 def test_battery_metadata_absent(self): @@ -646,7 +646,7 @@ def test_battery_metadata_absent(self): snapshot = consumer.build_snapshot() assert snapshot.battery.soe_percentage == 50.0 assert snapshot.battery.vendor_name is None - assert snapshot.battery.product_name is None + assert snapshot.battery.model is None assert snapshot.battery.nameplate_capacity_kwh is None @@ -677,7 +677,7 @@ def test_pv_metadata_parsed(self): snapshot = consumer.build_snapshot() assert snapshot.pv.vendor_name == "Enphase" - assert snapshot.pv.product_name == "IQ8+" + assert snapshot.pv.model == "IQ8+" assert snapshot.pv.nameplate_capacity_w == 3960.0 assert snapshot.pv.feed_circuit_id == "aabbccdd112233445566778899001122" assert snapshot.pv.relative_position == "IN_PANEL" @@ -687,7 +687,7 @@ def test_no_pv_node(self): acc, consumer = _build_ready_consumer({"core": {"type": TYPE_CORE}}) snapshot = consumer.build_snapshot() assert snapshot.pv.vendor_name is None - assert snapshot.pv.product_name is None + assert snapshot.pv.model is None assert snapshot.pv.nameplate_capacity_w is None assert snapshot.pv.feed_circuit_id is None assert snapshot.pv.relative_position is None @@ -704,7 +704,7 @@ def test_pv_metadata_partial(self): snapshot = consumer.build_snapshot() assert snapshot.pv.vendor_name == "Other" - assert snapshot.pv.product_name is None + assert snapshot.pv.model is None assert snapshot.pv.nameplate_capacity_w is None assert snapshot.pv.feed_circuit_id is None assert snapshot.pv.relative_position is None @@ -1487,7 +1487,7 @@ def test_evse_metadata_parsed(self): assert evse.lock_state == "LOCKED" assert evse.advertised_current_a == 32.0 assert evse.vendor_name == "SPAN" - assert evse.product_name == "SPAN Drive" + assert evse.model == "SPAN Drive" assert evse.part_number == "SPN-DRV-001" assert evse.serial_number == "SN12345" assert evse.software_version == "2.1.0" @@ -1540,7 +1540,7 @@ def test_evse_partial_metadata(self): assert evse.lock_state == "UNKNOWN" assert evse.advertised_current_a is None assert evse.vendor_name is None - assert evse.product_name is None + assert evse.model is None assert evse.part_number is None assert evse.serial_number is None assert evse.software_version is None diff --git a/tests/test_schema_migration_delta.py b/tests/test_schema_migration_delta.py index 5c9c9b5..778a600 100644 --- a/tests/test_schema_migration_delta.py +++ b/tests/test_schema_migration_delta.py @@ -54,18 +54,18 @@ generally. Some members are probably misclassified, but **in the benign direction**, and the reason is worth understanding because it generalises. -A user does not see which property a value came from; they see the value. So the -adapter is free to re-source a field as long as the *meaning* survives, and for -BESS identity it deliberately does: - - info/part-number -> battery.model (the SKU stays in `model`) - info/model -> battery.product_name (the designation gets a new field) - -Flat firmware publishes `bess/model` as the SKU. v1.0's `battery.model` is also -the SKU, by that mapping. So on a flat capture that carried BESS identity, -`battery.model` would reclassify as **identity** — not as a semantic change — and -`battery.serial_number` likewise. Only the two `product_name` fields look like -genuine additions, because the designation had no flat home at all. +A user does not see which property a value came from; they see the value. Until +2026-08-10 this adapter used that latitude to *cross over* — `info/part-number` onto +`battery.model`, `info/model` onto `battery.product_name` — holding each entity's +displayed meaning still against flat, which irregularly puts the SKU in `bess/model` +where the EVSE puts it in `part-number`. + +That worked and permanently encoded flat's irregularity. The snapshot now speaks +v1.0's vocabulary on every device class, and `schema_0` translates flat into it: +`bess/model` becomes `part_number`, `product-name` becomes `model`. So on a flat +capture that carried BESS identity, all three provisional rows would reclassify as +**identity** rather than as semantic change — including the designation, which under +flat's own names looked like it had no home. The general point: a re-sourced field is a migration risk only when the mapper passes the change through. Where it absorbs the change, the delta is real in the @@ -79,9 +79,10 @@ it. And adding a field is not the same act as changing one: a new field cannot break an automation that never referenced it. -So `battery.product_name` is a free win rather than a hazard, and the delta -document treats "keep the SKU in `battery.model`" as a product call worth -revisiting rather than a default. `EXPECTED_ORPHANS` and `PROVISIONAL_DER` are +"Keep the SKU in `battery.model`" was exactly such a product call, and it was +revisited: the delta document's DER identity decision retired it in favour of +speaking v1.0's vocabulary, on the reasoning that a change we schedule in a library +release beats the same change arriving unplanned during a firmware upgrade. `EXPECTED_ORPHANS` and `PROVISIONAL_DER` are about entities that would *stop* arriving; nothing here argues against surfacing new ones. @@ -136,21 +137,19 @@ now read rather than derived and carry the same values a user has today — `DSM_ON_GRID` / `PANEL_ON_GRID` on the tracked capture. -Zero is the state worth defending, so the dict stays and the check below holds it. -""" -"""Fields that survive the migration as entities but stop carrying an answer. - -Worse for a user than an orphan, because an orphan goes stale and is noticeable -while `UNKNOWN` reads as a working sensor that does not know. Both members are -already documented; the test exists so a third cannot appear quietly. +A degraded field is worse for a user than an orphan: an orphan goes stale and is +noticeable, while `UNKNOWN` reads as a working sensor that does not know. Zero is the +state worth defending, so the dict stays and the check below holds it. """ ATTESTED_AGAINST_FIRMWARE: dict[str, str] = { - "pv.product_name": ( + "pv.model": ( "classified an addition here only because the frozen simulator never sends " "pv/product-name. A capture from real flat firmware does send it, so this is an " "IDENTITY — the entity exists today and survives the migration. Measured by " - "test_live_flat_differential.py; the simulator gap is recorded there as KNOWN_GAPS" + "test_live_flat_differential.py; the simulator gap is recorded there as KNOWN_GAPS. " + "Was pv.product_name until the 2026-08-10 identity normalisation; the field it " + "names is the same one, reached by the same flat property" ), } """Rows the mechanical diff gets wrong, corrected by a capture from real firmware. @@ -164,7 +163,7 @@ PROVISIONAL_DER: frozenset[str] = frozenset( { "battery.model", - "battery.product_name", + "battery.part_number", "battery.serial_number", } ) @@ -177,12 +176,13 @@ are attested. These four are the fields nothing sends and therefore nothing can vouch for. -Expect this set to shrink toward **identity**, not toward semantic change. The -mapper re-sources `battery.model` from `info/part-number`, which is the SKU that -flat's `bess/model` also carried, so a flat capture with BESS identity would move -`battery.model` and `battery.serial_number` into the identity bucket. The two -`product_name` entries are likely genuine additions: the designation had no flat -home. +Expect this set to shrink toward **identity**, not toward semantic change, and after +the 2026-08-10 identity normalisation that is now true of every member. Both adapters +speak v1.0's vocabulary — `schema_0` translates flat's `bess/model` to `part_number` +and its `product-name` to `model` — so a flat capture carrying BESS identity would +move all three into the identity bucket at once. Before the normalisation the two +`product_name` entries looked like genuine additions, because the designation had no +flat home under flat's own names; it does under these. Resolving this needs a capture from flat firmware with a BESS attached, which no available panel has. @@ -335,6 +335,35 @@ def test_evse_identity_survives_the_migration(flat: Any, parent_child: Any) -> N }, "the two captures feed their EVSEs from different circuits, so they are not the same panel" +def test_der_identity_reads_the_same_on_both_adapters(flat: Any, parent_child: Any) -> None: + """The point of the identity normalisation, measured on the one DER that can show it. + + Both adapters now speak v1.0's vocabulary -- `model` is the human designation and + `part_number` the SKU, on every device class -- with `schema_0` translating flat's + irregular naming rather than mirroring it. When both produce the same field *and* the + same value, DER identity stops being a migration delta at all. + + The EVSE is the only device class that can demonstrate it: flat publishes full + identity for it, none at all for the BESS, and `vendor-name` alone for PV. Those gaps + are the frozen simulator's, recorded in `PROVISIONAL_DER`, not the mapping's. + + Worth being explicit that this is the *library upgrade* being made a no-op, not the + firmware migration. `battery.model` does change value for existing flat users when + they take this release -- it gains the designation where it carried the SKU. That is + the deliberate trade: one change we schedule beats the same change arriving unplanned + during a firmware upgrade. + """ + flat_evse = sorted(flat.evse.values(), key=lambda e: e.serial_number or "") + pc_evse = sorted(parent_child.evse.values(), key=lambda e: e.serial_number or "") + assert len(flat_evse) == len(pc_evse) > 0 + + for before, after in zip(flat_evse, pc_evse, strict=True): + for field in ("model", "part_number", "vendor_name", "serial_number", "software_version"): + assert getattr(before, field) == getattr(after, field), ( + f"evse.{field} differs across the migration: " f"{getattr(before, field)!r} -> {getattr(after, field)!r}" + ) + + def test_no_circuit_field_is_orphaned(flat: Any, parent_child: Any) -> None: """Circuits are 96% of the entity surface and the attested part of the flat reference, so this is the strongest claim the harness can make.""" diff --git a/tests/test_schema_one_devices.py b/tests/test_schema_one_devices.py index de2408a..e5dc831 100644 --- a/tests/test_schema_one_devices.py +++ b/tests/test_schema_one_devices.py @@ -78,17 +78,28 @@ def test_battery_state_of_charge_and_energy() -> None: assert battery.vendor_name == "Span" -def test_battery_model_and_product_name_are_swapped_not_copied() -> None: - """Flat `bess/model` was the SKU; v1.0 `info/model` is the designation and - the SKU moved to `info/part-number`. Mapping info/model onto `model` would - keep the entity and change what it displays.""" +def test_battery_identity_is_read_straight_through_without_a_swap() -> None: + """The crossover is gone: the snapshot speaks v1.0's vocabulary directly. + + This asserted the opposite until 2026-08-10 -- `info/model` onto `product_name` and + `info/part-number` onto `model` -- to hold each entity's displayed meaning still + against flat, which puts the SKU in `bess/model`. It worked, and it permanently + encoded flat's irregularity in the snapshot, so every reader had to be told why + `battery.model` was not a model. + + Flat is the inconsistent side, not v1.0: it puts the SKU in `model` on the BESS and + in `part-number` on the EVSE, for the same concept. v1.0 normalises all three. So the + snapshot adopts v1.0's names and `schema_0` translates flat into them -- which also + moves the change off the firmware migration, where a user meets it unplanned, and + onto a library release we schedule. + """ bess = _device("bess") bess.update_property("info", "part-number", "1232100-00-E") battery = build_battery(bess, []) - assert battery.product_name == "Example BESS" # designation - assert battery.model == "1232100-00-E" # SKU + assert battery.model == "Example BESS" # designation, from info/model + assert battery.part_number == "1232100-00-E" # SKU, from info/part-number def test_battery_connected_comes_from_the_owner_not_the_bess() -> None: @@ -130,7 +141,7 @@ def test_pv_metadata_and_feed() -> None: pv = build_pv(_device("pv"), feed_circuit_ids(_circuits())) assert pv.vendor_name == "Enphase" - assert pv.product_name == "IQ8PLUS-72-2-US" + assert pv.model == "IQ8PLUS-72-2-US" assert pv.nameplate_capacity_w == 10000.0 assert pv.feed_circuit_id == SOLAR_CIRCUIT @@ -159,7 +170,7 @@ def test_evse_state_and_metadata() -> None: assert evse.lock_state == "LOCKED" assert evse.advertised_current_a == 32.0 assert evse.vendor_name == "SPAN" - assert evse.product_name == "SPAN Drive" + assert evse.model == "SPAN Drive" assert evse.part_number == "SPN-DRV-001" assert evse.serial_number == "SIM-EVSE-example-40t-001" diff --git a/tests/test_schema_one_snapshot.py b/tests/test_schema_one_snapshot.py index 9abff6e..197beee 100644 --- a/tests/test_schema_one_snapshot.py +++ b/tests/test_schema_one_snapshot.py @@ -90,7 +90,7 @@ def test_a_circuit_feeding_a_der_reports_the_der_type(snapshot: SpanPanelSnapsho def test_der_snapshots_are_populated(snapshot: SpanPanelSnapshot) -> None: assert snapshot.battery.soe_percentage == pytest.approx(50.4104, rel=1e-4) assert snapshot.battery.connected is True - assert snapshot.pv.product_name == "IQ8PLUS-72-2-US" + assert snapshot.pv.model == "IQ8PLUS-72-2-US" assert snapshot.pv.feed_circuit_id == SOLAR_CIRCUIT # Keyed by serial, not by device id: on real flat firmware the EVSE node id is # the Drive's serial (SpanPanel/span#214), so this is what keeps a charger's diff --git a/uv.lock b/uv.lock index e177fa2..0c8113b 100644 --- a/uv.lock +++ b/uv.lock @@ -1323,7 +1323,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "3.0.0b2" +version = "3.0.0b3" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -1380,7 +1380,7 @@ dev = [ [[package]] name = "span-panel-api-schema-0" -version = "1.0.0b2" +version = "1.0.0b3" source = { editable = "packages/schema-0" } dependencies = [ { name = "span-panel-api" }, @@ -1391,7 +1391,7 @@ requires-dist = [{ name = "span-panel-api", editable = "." }] [[package]] name = "span-panel-api-schema-1" -version = "0.1.0b2" +version = "0.1.0b3" source = { editable = "packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From 03ebc8b2e56bce5916c6c3016479b952704ee828 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:41:30 -0700 Subject: [PATCH 064/115] feat(schema_1): restore dominant_power_source, and name the forming device readably `panel.dominant_power_source` was the last of the four broken grid entities, recorded as an orphan on the reasoning that v1.0 answers the question with a device id and surfacing that would change the entity's value space from a closed enum to an open string. True of the raw value; not true once the id is dereferenced. The integration's sensor for this field is already named `grid_forming_entity`, so v1.0's `grid/grid-forming-entity` is the same concept it has always shown -- not a successor to negotiate, and no place for a second entity. What changed is only the encoding, and `build_snapshot` already resolves device ids to classes for `current_run_config`; the same map answers this. GRID -> GRID ...device.bess -> BATTERY ...device.pv -> PV anything else -> UNKNOWN Nothing can escape as a raw id: unresolvable collapses to UNKNOWN, which flat's enum already had and consumers already handle. The device-type registry instructs consumers to tolerate unknown `$type` values, and this is what that looks like from a consumer. `GENERATOR` has no row because the registry has no generator device -- flat computed a source class, v1.0 names a device, and there is no generator device to name yet. **The precision v1.0 adds is surfaced beside the field, not inside it.** `SpanMidSnapshot.grid_forming_device_name` carries the forming device's Homie `$description.name` -- `Battery`, `Solar`, `SPAN Drive - Garage`. The raw wire value stays on `grid_forming_entity` for anyone who needs the literal. A Homie device id is not a Home Assistant device id: `sim-40t-001-SIM-BESS-40T-001` on a dashboard is worse than nothing, so the readable name is the part worth exposing. Absorb the change in the state entity that exists, surface the addition separately -- a changed value breaks automations silently, a new field cannot. EXPECTED_ORPHANS drops to two. Falsified twice: letting an unmapped class through as the raw id, and returning the wire id instead of the display name, each fail exactly the test named for that failure. 607 passed, mypy clean. --- .../src/span_panel_api_schema_1/devices.py | 7 +- .../src/span_panel_api_schema_1/panel.py | 68 +++++++++++++++++++ .../src/span_panel_api_schema_1/snapshot.py | 11 ++- src/span_panel_api/models.py | 10 ++- tests/test_schema_migration_delta.py | 11 +-- tests/test_schema_one_devices.py | 4 +- tests/test_schema_one_panel.py | 51 +++++++++++++- 7 files changed, 149 insertions(+), 13 deletions(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/devices.py b/packages/schema-1/src/span_panel_api_schema_1/devices.py index 1525404..eed2bda 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/devices.py +++ b/packages/schema-1/src/span_panel_api_schema_1/devices.py @@ -33,9 +33,11 @@ NODE_SWITCH, UNKNOWN, ) -from span_panel_api_schema_1.panel import number, text +from span_panel_api_schema_1.panel import number, resolve_grid_forming_device_name, text if TYPE_CHECKING: + from collections.abc import Mapping + from ebus_sdk.homie import DiscoveredDevice PROP_FIRMWARE_VERSION = "firmware-version" @@ -164,7 +166,7 @@ def build_evse(evse: DiscoveredDevice, feeds: dict[str, str], *, node_id: str) - PROP_GRID_FORMING_ENTITY = "grid-forming-entity" -def build_mid(mid: DiscoveredDevice | None) -> SpanMidSnapshot | None: +def build_mid(mid: DiscoveredDevice | None, device_names: Mapping[str, str]) -> SpanMidSnapshot | None: """Build the MID snapshot, or `None` when the panel publishes no MID. `None` is the presence signal, so there is nothing for a consumer to infer from a @@ -189,4 +191,5 @@ def build_mid(mid: DiscoveredDevice | None) -> SpanMidSnapshot | None: islanding_state=_optional(text(mid, NODE_GRID, PROP_ISLANDING_STATE)), grid_state=_optional(text(mid, NODE_GRID, PROP_GRID_STATE)), grid_forming_entity=_optional(text(mid, NODE_GRID, PROP_GRID_FORMING_ENTITY)), + grid_forming_device_name=resolve_grid_forming_device_name(mid, device_names), ) diff --git a/packages/schema-1/src/span_panel_api_schema_1/panel.py b/packages/schema-1/src/span_panel_api_schema_1/panel.py index 6301192..ad097aa 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/panel.py +++ b/packages/schema-1/src/span_panel_api_schema_1/panel.py @@ -56,6 +56,7 @@ PROP_VOLTAGE_B, PROP_WIFI, TYPE_BESS, + TYPE_PV, UNKNOWN, UNMAPPED_TAB_PREFIX, ) @@ -439,3 +440,70 @@ def resolve_grid_islandable(inverters: Sequence[DiscoveredDevice]) -> bool | Non if not known: return None return any(known) + + +# Flat's `dominant-power-source` enum, keyed by the device class v1.0 names instead. +# `GENERATOR` has no row because the device-type registry has no generator: flat's value +# came from the panel computing a source class, v1.0 names an actual device, and there is +# no generator device to name yet. One row when there is. +_POWER_SOURCE_BY_TYPE: dict[str, str] = { + TYPE_BESS: "BATTERY", + TYPE_PV: "PV", +} + + +def resolve_dominant_power_source( + mid: DiscoveredDevice | None, + device_types: Mapping[str, str], +) -> str | None: + """Flat's `dominant_power_source`, from the MID's grid-forming entity. + + The integration's entity for this field is already named `grid_forming_entity`, so + v1.0's `grid/grid-forming-entity` is the same concept it has always shown — not a + successor to negotiate. What changed is the encoding: flat published a closed enum of + source *classes*, v1.0 names the actual *device*. Dereferencing the id against the + tree recovers the class, so the entity keeps its value space and nothing comparing + against `BATTERY` stops matching. + + **Anything unresolvable becomes `UNKNOWN`, which is in flat's enum already.** A device + id naming something outside this tree, or a class with no row above, cannot escape as + a raw id — the device-type registry itself instructs consumers to tolerate unknown + `$type` values, and this is what tolerating one looks like from a consumer. + + The precision v1.0 adds — *which* battery, distinguishable when a site has two — is + not discarded, it is surfaced beside this rather than inside it, as + `SpanMidSnapshot.grid_forming_device_name`. Absorb the change in the state entity that + exists, surface the addition separately: a changed value breaks automations silently, + a new field cannot. + + `None` rather than `UNKNOWN` when there is no MID or no answer at all, matching what + the field already does on a panel that publishes nothing. + """ + forming = text(mid, NODE_GRID, PROP_GRID_FORMING_ENTITY).strip() + if not forming: + return None + if forming.upper() == "GRID": + return "GRID" + return _POWER_SOURCE_BY_TYPE.get(device_types.get(forming, ""), UNKNOWN) + + +def resolve_grid_forming_device_name( + mid: DiscoveredDevice | None, + device_names: Mapping[str, str], +) -> str | None: + """The readable name of whatever is forming the grid, or `None` when it is the grid. + + The wire value is a Homie device id -- `sim-40t-001-SIM-BESS-40T-001`. That means + nothing to someone reading a dashboard: it is not a Home Assistant device id, and an + opaque string is worse than no string. Homie's `$description.name` is the device's + own display name (`Battery`, `Solar`, `SPAN Drive - Garage`), which is what a person + would recognise, so that is what gets surfaced. + + `None` when the grid is forming (there is no device to name), when no MID publishes + an answer, or when the id resolves to nothing -- the raw id is still on + `grid_forming_entity` for anyone who needs the literal value. + """ + forming = text(mid, NODE_GRID, PROP_GRID_FORMING_ENTITY).strip() + if not forming or forming.upper() == "GRID": + return None + return device_names.get(forming) diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index 3b695cf..cdb6e52 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -31,6 +31,7 @@ build_unmapped_tabs, find_lugs, panel_size_from_model, + resolve_dominant_power_source, resolve_dsm_state, resolve_grid_islandable, resolve_islanding_state, @@ -125,6 +126,12 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re # is what makes PANEL_BACKUP distinguishable from PANEL_OFF_GRID authoritatively # instead of guessed from a power source the way flat had to. device_types = {device.device_id: device_type(device) for device in children} + device_names: dict[str, str] = {} + for device in children: + description: dict[str, object] = device.description or {} + name = description.get("name") + if name: + device_names[device.device_id] = str(name) inverters = [device for device in children if device_type(device) == TYPE_INVERTER] islanding = resolve_islanding_state(roles.mid, panel) @@ -153,7 +160,7 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re wlan_link=fields.wlan_link, wwan_link=fields.wwan_link, panel_size=panel_size, - dominant_power_source=fields.dominant_power_source, + dominant_power_source=resolve_dominant_power_source(roles.mid, device_types), grid_state=fields.grid_state, grid_islandable=resolve_grid_islandable(inverters), l1_voltage=fields.l1_voltage, @@ -172,7 +179,7 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re circuits=circuits, battery=build_battery(roles.bess, owners), pv=build_pv(roles.pv, feeds), - mid=build_mid(roles.mid), + mid=build_mid(roles.mid, device_names), evse={key: build_evse(device, feeds, node_id=key) for device, key in _harmonised_evse_keys(roles.evse).items()}, ) diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index b1f127b..6cea4ba 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -101,7 +101,15 @@ class SpanMidSnapshot: grid_state: str | None = None """`grid/grid-state` — whether utility power is present, distinct from islanding.""" grid_forming_entity: str | None = None - """`grid/grid-forming-entity` — which device is currently forming the grid.""" + """`grid/grid-forming-entity` — the raw wire value: `GRID`, or a Homie device id.""" + grid_forming_device_name: str | None = None + """The forming device's display name, or `None` when the grid itself is forming. + + The raw value above is a Homie device id, which means nothing on a dashboard — it is + not a Home Assistant device id, and an opaque string is worse than none. This is the + device's own `$description.name` (`Battery`, `Solar`, `SPAN Drive - Garage`), which + is the part a person can read. The literal stays available beside it. + """ @dataclass(frozen=True, slots=True) diff --git a/tests/test_schema_migration_delta.py b/tests/test_schema_migration_delta.py index 778a600..e85ab30 100644 --- a/tests/test_schema_migration_delta.py +++ b/tests/test_schema_migration_delta.py @@ -113,11 +113,12 @@ _SERIAL = "sim-40t-001" EXPECTED_ORPHANS: dict[str, str] = { - "panel.dominant_power_source": ( - "split upstream into grid/grid-forming-entity and shed/asserted-islanding-state, " - "which are different controls on different devices; which successor is exposed, " - "if any, is an open product decision" - ), + # `panel.dominant_power_source` was here until 2026-08-10. It is populated now: the + # integration's entity for it is already named `grid_forming_entity`, so v1.0's + # `grid/grid-forming-entity` is the same concept, and dereferencing the device id + # against the tree recovers flat's source-class enum. The precision v1.0 adds -- + # *which* device -- is surfaced beside it as `mid.grid_forming_device_name` rather + # than inside it, so no automation meets a value it has never seen. "panel.grid_islandable": ( "no v1.0 source; the flat panel advertised islandability as a panel property and " "the redesign expresses it through the presence of a MID instead" diff --git a/tests/test_schema_one_devices.py b/tests/test_schema_one_devices.py index e5dc831..4801713 100644 --- a/tests/test_schema_one_devices.py +++ b/tests/test_schema_one_devices.py @@ -193,7 +193,7 @@ def test_the_mid_is_surfaced_as_its_own_device() -> None: identity drops to the Homie device id. `test_the_mid_identity_is_its_serial` covers the path that matters more, against a capture that has one. """ - mid = build_mid(_device("bess-mid")) + mid = build_mid(_device("bess-mid"), {}) assert mid is not None assert mid.islanding_state == "ON_GRID" @@ -211,4 +211,4 @@ def test_a_panel_with_no_mid_reports_none_rather_than_an_empty_device() -> None: is always present; its own docstring records that only that one field is reliable. A new optional device should not inherit that guessing game. """ - assert build_mid(None) is None + assert build_mid(None, {}) is None diff --git a/tests/test_schema_one_panel.py b/tests/test_schema_one_panel.py index c3f9f21..37ad586 100644 --- a/tests/test_schema_one_panel.py +++ b/tests/test_schema_one_panel.py @@ -13,13 +13,15 @@ from ebus_sdk.homie import DiscoveredDevice -from span_panel_api_schema_1.const import NODE_GRID, TYPE_BESS +from span_panel_api_schema_1.const import NODE_GRID, TYPE_BESS, TYPE_PV from span_panel_api_schema_1.panel import ( PanelFields, build_unmapped_tabs, find_lugs, panel_model_drift, panel_size_from_model, + resolve_dominant_power_source, + resolve_grid_forming_device_name, resolve_grid_islandable, resolve_islanding_state, resolve_run_config, @@ -389,3 +391,50 @@ def test_an_inverter_that_says_nothing_is_unknown_not_incapable() -> None: """ assert resolve_grid_islandable([_synthetic("inv-1")]) is None assert resolve_grid_islandable([]) is None + + +def test_dominant_power_source_dereferences_the_forming_device_to_a_class() -> None: + """The entity keeps flat's closed enum, so nothing comparing to `BATTERY` breaks. + + The integration's sensor for this field is already named `grid_forming_entity`, so + v1.0's property is the same concept it has always shown. Only the encoding changed: + flat published a source class, v1.0 names the device. Dereferencing recovers the + class from the tree. + """ + types = {"bess-1": TYPE_BESS, "pv-1": TYPE_PV} + + assert resolve_dominant_power_source(_synthetic("mid", grid__grid_forming_entity="GRID"), types) == "GRID" + assert resolve_dominant_power_source(_synthetic("mid", grid__grid_forming_entity="bess-1"), types) == "BATTERY" + assert resolve_dominant_power_source(_synthetic("mid", grid__grid_forming_entity="pv-1"), types) == "PV" + + +def test_an_unresolvable_forming_entity_cannot_escape_as_a_raw_id() -> None: + """`UNKNOWN` is in flat's enum already, so the value space stays closed. + + A device id naming something outside this tree, or a class with no mapping, must not + reach an entity as an opaque string — that is exactly the silent break the decision + to dereference exists to avoid. The device-type registry instructs consumers to + tolerate unknown `$type` values; this is what tolerating one looks like. + """ + stranger = _synthetic("mid", grid__grid_forming_entity="some-device-not-in-this-tree") + unmapped = _synthetic("mid", grid__grid_forming_entity="wh-1") + + assert resolve_dominant_power_source(stranger, {}) == "UNKNOWN" + assert resolve_dominant_power_source(unmapped, {"wh-1": "energy.ebus.device.water-heater"}) == "UNKNOWN" + assert resolve_dominant_power_source(None, {}) is None + + +def test_the_forming_device_is_named_readably_not_by_wire_id() -> None: + """A Homie device id is not a Home Assistant device id. + + `sim-40t-001-SIM-BESS-40T-001` on a dashboard is worse than nothing. The device's own + `$description.name` is what a person recognises, and it is the precision v1.0 adds + over flat — *which* battery, not merely that a battery is forming. + """ + names = {"bess-1": "Battery", "pv-1": "Solar"} + + assert resolve_grid_forming_device_name(_synthetic("mid", grid__grid_forming_entity="bess-1"), names) == "Battery" + # The grid is not a device, so there is nothing to name. + assert resolve_grid_forming_device_name(_synthetic("mid", grid__grid_forming_entity="GRID"), names) is None + # Unresolvable: the raw id stays on `grid_forming_entity` for anyone who needs it. + assert resolve_grid_forming_device_name(_synthetic("mid", grid__grid_forming_entity="ghost"), names) is None From 85bd670c9e4b9b377d94ffd2b349d18ecc355641 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:07:54 -0700 Subject: [PATCH 065/115] feat(schema_1): derive pv.relative_position, and map battery.software_version Two of the small blocked items, both now unblocked, and EXPECTED_ORPHANS drops to one. **`pv.relative_position` was never really an orphan.** v1.0 retired the property on purpose and the enclosure model states the replacement outright: "The position of a DER relative to the enclosure is derivable from which enclosure-side connection-owner references the DER." `resolve_relative_position` reads the connection records -- a circuit's `feeds-device-id` or the downstream lugs' means IN_PANEL, the upstream lugs' `fed-by-device-id` means UPSTREAM, nothing means None. Verified against the pair rather than reasoned: flat publishes `pv/relative-position = IN_PANEL` and `bess/relative-position = UPSTREAM`, and the derivation produces exactly those from a circuit feeding the PV and the upstream lugs being fed by the BESS. `None` where no owner references the DER, per the guide, and because the integration gates whether a control entity exists at all on this value -- inventing one creates or removes a control. The feedthrough branch is unreachable against every producer today, since no lugs device can publish `connection/feeds-*` (upstream #30); it is written because the rule has three cases and omitting one would read as a claim it cannot happen. **`battery.software_version` has a metadata row**, now that panelbench publishes a placeholder BESS firmware version (peer 0a867c3). The mapping always existed; the value did not. Recorded as synthetic in PROVISIONAL_DER rather than treated as attested -- the frozen flat simulator publishes no BESS identity at all, so nothing can vouch for it. The over-declaration check keeps its PV pair. It is doing real work while non-empty, and filling every gap with invented values would retire the signal without making anything truer; the BESS one was filled because a mapping was blocked on it, and the PV ones block nothing. 607 passed, mypy clean. --- .../spec/fixtures/simulator_tree.json | 76 +++---- .../spec/fixtures/simulator_wire.json | 185 +++++++++--------- .../src/span_panel_api_schema_1/devices.py | 62 +++++- .../span_panel_api_schema_1/field_metadata.py | 1 + .../src/span_panel_api_schema_1/snapshot.py | 2 +- .../span_panel_api_schema_1/spec_lock.json | 2 +- tests/test_schema_migration_delta.py | 12 +- tests/test_schema_one_adapter.py | 39 ++-- tests/test_schema_one_against_simulator.py | 10 +- 9 files changed, 226 insertions(+), 163 deletions(-) diff --git a/packages/schema-1/spec/fixtures/simulator_tree.json b/packages/schema-1/spec/fixtures/simulator_tree.json index 509f8d8..783181f 100644 --- a/packages/schema-1/spec/fixtures/simulator_tree.json +++ b/packages/schema-1/spec/fixtures/simulator_tree.json @@ -135,7 +135,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923432 + "version": 1786424627511 }, "1bfdc7ecebb0547bbe87a3696cddb0c0": { "children": [], @@ -273,7 +273,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923435 + "version": 1786424627514 }, "1eeeb748eeaa58edb7e9b7e9dbbdeca7": { "children": [], @@ -411,7 +411,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923434 + "version": 1786424627513 }, "2140a7e253ed54e3bc90a959081df615": { "children": [], @@ -549,7 +549,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923433 + "version": 1786424627511 }, "249a2f59782e5f1ab317c4632e79afad": { "children": [], @@ -687,7 +687,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923435 + "version": 1786424627514 }, "3d9d86f303cc50d1827be57d4c667e53": { "children": [], @@ -825,7 +825,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923431 + "version": 1786424627509 }, "3eeb0eb1605e5a7eadac41994b7a096c": { "children": [], @@ -963,7 +963,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923431 + "version": 1786424627510 }, "43a0521737db516f99f14a9964ea4af0": { "children": [], @@ -1101,7 +1101,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923433 + "version": 1786424627512 }, "4aeb08c46c2c5905a944166413f2f1ef": { "children": [], @@ -1239,7 +1239,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923434 + "version": 1786424627512 }, "4ce8b30e8d3f5c49b9e0ab0c8caf4832": { "children": [], @@ -1377,7 +1377,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923435 + "version": 1786424627514 }, "4d1deb6acb065746b13207b1358f8ca7": { "children": [], @@ -1515,7 +1515,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923433 + "version": 1786424627512 }, "516694a326a35cd88600b3520e8a981a": { "children": [], @@ -1653,7 +1653,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923434 + "version": 1786424627512 }, "6fcb352679ad5bfb8c8a8eab06829b9f": { "children": [], @@ -1791,7 +1791,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923435 + "version": 1786424627514 }, "770e2de52c33508a8a9ee8878064b46f": { "children": [], @@ -1929,7 +1929,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923430 + "version": 1786424627509 }, "80a4fada833156ab8112f9d50e252b8f": { "children": [], @@ -2067,7 +2067,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923432 + "version": 1786424627510 }, "9429f828509e58d59cb5f0f9f5fee523": { "children": [], @@ -2205,7 +2205,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923431 + "version": 1786424627509 }, "948dea7788aa5c959b99df0edfabead2": { "children": [], @@ -2343,7 +2343,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923435 + "version": 1786424627513 }, "af731c49a6785a4cb2ea5549fb8bce7e": { "children": [], @@ -2481,7 +2481,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923434 + "version": 1786424627513 }, "afe90839f2725e3e962fb05afa2b6d43": { "children": [], @@ -2619,7 +2619,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923433 + "version": 1786424627512 }, "b24483358d29589d8e91d3bf11113269": { "children": [], @@ -2757,7 +2757,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923432 + "version": 1786424627511 }, "b9fa08f1eaaf5d129bd5c78e1d5d937f": { "children": [], @@ -2895,7 +2895,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923436 + "version": 1786424627514 }, "be7742043a06554aab2a1e38cc776603": { "children": [], @@ -3033,7 +3033,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923435 + "version": 1786424627513 }, "c058aa11287f50f9b81e5160a0678869": { "children": [], @@ -3171,7 +3171,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923431 + "version": 1786424627510 }, "c339ec7ce7ff521ca7646f9606baff9f": { "children": [], @@ -3309,7 +3309,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923433 + "version": 1786424627511 }, "d1ff145887a05b839ede89409c27b398": { "children": [], @@ -3447,7 +3447,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923432 + "version": 1786424627511 }, "e0ac90e169e6550ea83fe0b1942f1d0e": { "children": [], @@ -3585,7 +3585,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923432 + "version": 1786424627510 }, "e0bc156c85015a609d4132084dfcd6fe": { "children": [], @@ -3723,7 +3723,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923433 + "version": 1786424627512 }, "edee3425d50d51ffb022ee999053b2b4": { "children": [], @@ -3861,7 +3861,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923432 + "version": 1786424627511 }, "ef972f063451539e8b2ad88e831d87b6": { "children": [], @@ -3999,7 +3999,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923434 + "version": 1786424627513 }, "f515a0f43b6555b1a196fbb62728c24e": { "children": [], @@ -4137,7 +4137,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.circuit", - "version": 1786400923431 + "version": 1786424627510 }, "sim-40t-001": { "children": [ @@ -4443,7 +4443,7 @@ } }, "type": "energy.ebus.device.distribution-enclosure", - "version": 1786400923436 + "version": 1786424627515 }, "sim-40t-001-SIM-BESS-40T-001": { "children": [ @@ -4526,7 +4526,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.bess", - "version": 1786400923436 + "version": 1786424627515 }, "sim-40t-001-SIM-BESS-40T-001-mid": { "children": [], @@ -4584,7 +4584,7 @@ "parent": "sim-40t-001-SIM-BESS-40T-001", "root": "sim-40t-001", "type": "energy.ebus.device.mid", - "version": 1786400923436 + "version": 1786424627515 }, "sim-40t-001-SIM-EVSE-sim-40t-001": { "children": [], @@ -4672,7 +4672,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.evse", - "version": 1786400923436 + "version": 1786424627514 }, "sim-40t-001-SIM-EVSE-sim-40t-001-2": { "children": [], @@ -4760,7 +4760,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.evse", - "version": 1786400923436 + "version": 1786424627515 }, "sim-40t-001-lugs-dn": { "children": [], @@ -4850,7 +4850,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.lugs", - "version": 1786400923436 + "version": 1786424627515 }, "sim-40t-001-lugs-up": { "children": [], @@ -4940,7 +4940,7 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.lugs", - "version": 1786400923436 + "version": 1786424627515 }, "sim-40t-001-pv-1": { "children": [], @@ -4979,6 +4979,6 @@ "parent": "sim-40t-001", "root": "sim-40t-001", "type": "energy.ebus.device.pv", - "version": 1786400923436 + "version": 1786424627515 } } diff --git a/packages/schema-1/spec/fixtures/simulator_wire.json b/packages/schema-1/spec/fixtures/simulator_wire.json index 5755b6f..91c7664 100644 --- a/packages/schema-1/spec/fixtures/simulator_wire.json +++ b/packages/schema-1/spec/fixtures/simulator_wire.json @@ -1,14 +1,14 @@ { "13044bfbcbe5554b8f3dba126bce828f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923432, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Island)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627511, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Island)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Kitchen Outlets (Island)", "info/spaces": "10", "load-shed/priority": "NEVER", - "meter/active-power": "-344.1586425497289", - "meter/current": "2.867988687914407", + "meter/active-power": "-281.6875885153822", + "meter/current": "2.3473965709615183", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -18,7 +18,7 @@ "switch/relay-requester": "NONE" }, "1bfdc7ecebb0547bbe87a3696cddb0c0": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923435, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627514, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "50", @@ -39,15 +39,15 @@ "switch/relay-requester": "NONE" }, "1eeeb748eeaa58edb7e9b7e9dbbdeca7": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923434, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Smoke Detectors\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627513, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Smoke Detectors\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Smoke Detectors", "info/spaces": "40", "load-shed/priority": "NEVER", - "meter/active-power": "-4.66117531222462", - "meter/current": "0.03884312760187183", + "meter/active-power": "-5.058420622591603", + "meter/current": "0.04215350518826336", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -57,15 +57,15 @@ "switch/relay-requester": "NONE" }, "2140a7e253ed54e3bc90a959081df615": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923433, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Refrigerator\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627511, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Refrigerator\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Refrigerator", "info/spaces": "15", "load-shed/priority": "NEVER", - "meter/active-power": "-103.94731923028247", - "meter/current": "0.866227660252354", + "meter/active-power": "-116.70028748537023", + "meter/current": "0.9725023957114185", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "false", @@ -75,7 +75,7 @@ "switch/relay-requester": "NONE" }, "249a2f59782e5f1ab317c4632e79afad": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923435, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627514, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "50", @@ -96,15 +96,15 @@ "switch/relay-requester": "NONE" }, "3d9d86f303cc50d1827be57d4c667e53": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923431, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627509, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Bedroom Lights", "info/spaces": "4", "load-shed/priority": "NEVER", - "meter/active-power": "-7.300265427089079", - "meter/current": "0.060835545225742325", + "meter/active-power": "-61.55461466164948", + "meter/current": "0.5129551221804124", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -114,15 +114,15 @@ "switch/relay-requester": "NONE" }, "3eeb0eb1605e5a7eadac41994b7a096c": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923431, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627510, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Master Bedroom Outlets", "info/spaces": "7", "load-shed/priority": "NEVER", - "meter/active-power": "-166.12973595258822", - "meter/current": "1.3844144662715685", + "meter/active-power": "-145.97614877367525", + "meter/current": "1.2164679064472936", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -132,15 +132,15 @@ "switch/relay-requester": "NONE" }, "43a0521737db516f99f14a9964ea4af0": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923433, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Washing Machine\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627512, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Washing Machine\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Washing Machine", "info/spaces": "17", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-1188.0673538133055", - "meter/current": "9.900561281777547", + "meter/active-power": "0.0", + "meter/current": "0.0", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -150,7 +150,7 @@ "switch/relay-requester": "NONE" }, "4aeb08c46c2c5905a944166413f2f1ef": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923434, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garbage Disposal\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627512, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garbage Disposal\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", @@ -168,15 +168,15 @@ "switch/relay-requester": "NONE" }, "4ce8b30e8d3f5c49b9e0ab0c8caf4832": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923435, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Water Heater\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627514, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Water Heater\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", "info/name": "Water Heater", "info/spaces": "31,33", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-2592.5524192875587", - "meter/current": "10.802301747031494", + "meter/active-power": "-2572.8468862666637", + "meter/current": "10.720195359444432", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -186,7 +186,7 @@ "switch/relay-requester": "NONE" }, "4d1deb6acb065746b13207b1358f8ca7": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923433, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Dishwasher\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627512, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Dishwasher\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", @@ -204,15 +204,15 @@ "switch/relay-requester": "NONE" }, "516694a326a35cd88600b3520e8a981a": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923434, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Pool Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627512, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Pool Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Pool Pump", "info/spaces": "39", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-690.8276310218992", - "meter/current": "5.756896925182493", + "meter/active-power": "0.0", + "meter/current": "0.0", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -222,7 +222,7 @@ "switch/relay-requester": "NONE" }, "6fcb352679ad5bfb8c8a8eab06829b9f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923435, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627514, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", @@ -232,8 +232,8 @@ "info/name": "Solar Inverter", "info/spaces": "36,38", "load-shed/priority": "NEVER", - "meter/active-power": "7327.47708818823", - "meter/current": "30.531154534117622", + "meter/active-power": "0.0", + "meter/current": "0.0", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "false", @@ -243,15 +243,15 @@ "switch/relay-requester": "NONE" }, "770e2de52c33508a8a9ee8878064b46f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923430, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627509, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Master Bedroom Lights", "info/spaces": "1", "load-shed/priority": "NEVER", - "meter/active-power": "-4.005376786632718", - "meter/current": "0.03337813988860598", + "meter/active-power": "-27.27036078511234", + "meter/current": "0.22725300654260286", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -261,15 +261,15 @@ "switch/relay-requester": "NONE" }, "80a4fada833156ab8112f9d50e252b8f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923432, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Counter)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627510, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Counter)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Kitchen Outlets (Counter)", "info/spaces": "9", "load-shed/priority": "NEVER", - "meter/active-power": "-280.8901198038773", - "meter/current": "2.3407509983656443", + "meter/active-power": "-342.2266364449327", + "meter/current": "2.851888637041106", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -279,15 +279,15 @@ "switch/relay-requester": "NONE" }, "9429f828509e58d59cb5f0f9f5fee523": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923431, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627509, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Living Room Lights", "info/spaces": "2", "load-shed/priority": "NEVER", - "meter/active-power": "-5.096271387725672", - "meter/current": "0.04246892823104727", + "meter/active-power": "-31.967894805836238", + "meter/current": "0.26639912338196864", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -297,15 +297,15 @@ "switch/relay-requester": "NONE" }, "948dea7788aa5c959b99df0edfabead2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923435, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Heat Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627513, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Heat Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", "info/name": "Heat Pump", "info/spaces": "27,29", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-1929.021910987512", - "meter/current": "8.0375912957813", + "meter/active-power": "-1877.229732459936", + "meter/current": "7.8217905519164", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -315,15 +315,15 @@ "switch/relay-requester": "NONE" }, "af731c49a6785a4cb2ea5549fb8bce7e": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923434, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Main HVAC\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627513, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Main HVAC\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", "info/name": "Main HVAC", "info/spaces": "23,25", "load-shed/priority": "NEVER", - "meter/active-power": "-738.5088737412302", - "meter/current": "3.077120307255126", + "meter/active-power": "-570.9799967955347", + "meter/current": "2.3790833199813948", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -333,15 +333,15 @@ "switch/relay-requester": "NONE" }, "afe90839f2725e3e962fb05afa2b6d43": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923433, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Chest Freezer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627512, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Chest Freezer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Chest Freezer", "info/spaces": "19", "load-shed/priority": "NEVER", - "meter/active-power": "-75.59263775126298", - "meter/current": "0.6299386479271916", + "meter/active-power": "-79.15726635065371", + "meter/current": "0.6596438862554476", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "false", @@ -351,15 +351,15 @@ "switch/relay-requester": "NONE" }, "b24483358d29589d8e91d3bf11113269": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923432, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Office Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627511, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Office Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Office Outlets", "info/spaces": "11", "load-shed/priority": "NEVER", - "meter/active-power": "-329.92180794873804", - "meter/current": "2.749348399572817", + "meter/active-power": "-322.53151973655616", + "meter/current": "2.6877626644713013", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -369,15 +369,15 @@ "switch/relay-requester": "NONE" }, "b9fa08f1eaaf5d129bd5c78e1d5d937f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923436, \"type\": \"energy.ebus.device.circuit\", \"name\": \"kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627514, \"type\": \"energy.ebus.device.circuit\", \"name\": \"kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "kitchen Lights", "info/spaces": "3", "load-shed/priority": "NEVER", - "meter/active-power": "-153.90154675440246", - "meter/current": "1.2825128896200204", + "meter/active-power": "-143.4203096288541", + "meter/current": "1.1951692469071176", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -387,7 +387,7 @@ "switch/relay-requester": "NONE" }, "be7742043a06554aab2a1e38cc776603": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923435, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Oven/Range\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627513, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Oven/Range\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "40", @@ -405,15 +405,15 @@ "switch/relay-requester": "NONE" }, "c058aa11287f50f9b81e5160a0678869": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923431, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bathroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627510, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bathroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Bathroom Lights", "info/spaces": "5", "load-shed/priority": "NEVER", - "meter/active-power": "-2.9413507735267856", - "meter/current": "0.024511256446056548", + "meter/active-power": "-19.07638814259435", + "meter/current": "0.15896990118828624", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -423,15 +423,15 @@ "switch/relay-requester": "NONE" }, "c339ec7ce7ff521ca7646f9606baff9f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923433, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Guest Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627511, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Guest Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Guest Room Outlets", "info/spaces": "14", "load-shed/priority": "NEVER", - "meter/active-power": "-155.11713699165023", - "meter/current": "1.292642808263752", + "meter/active-power": "-139.79811201824884", + "meter/current": "1.1649842668187405", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -441,15 +441,15 @@ "switch/relay-requester": "NONE" }, "d1ff145887a05b839ede89409c27b398": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923432, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627511, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Garage Outlets", "info/spaces": "12", "load-shed/priority": "NEVER", - "meter/active-power": "-127.61056242350686", - "meter/current": "1.0634213535292238", + "meter/active-power": "-139.43521019790484", + "meter/current": "1.1619600849825402", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -459,15 +459,15 @@ "switch/relay-requester": "NONE" }, "e0ac90e169e6550ea83fe0b1942f1d0e": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923432, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627510, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Living Room Outlets", "info/spaces": "8", "load-shed/priority": "NEVER", - "meter/active-power": "-269.24435321102357", - "meter/current": "2.2437029434251965", + "meter/active-power": "-233.85362788904922", + "meter/current": "1.9487802324087435", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -477,7 +477,7 @@ "switch/relay-requester": "NONE" }, "e0bc156c85015a609d4132084dfcd6fe": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923433, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Microwave\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627512, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Microwave\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", @@ -495,15 +495,15 @@ "switch/relay-requester": "NONE" }, "edee3425d50d51ffb022ee999053b2b4": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923432, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Laundry Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627511, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Laundry Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Laundry Room Outlets", "info/spaces": "13", "load-shed/priority": "NEVER", - "meter/active-power": "-135.20155504982048", - "meter/current": "1.1266796254151707", + "meter/active-power": "-165.95107388066228", + "meter/current": "1.3829256156721856", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -513,15 +513,15 @@ "switch/relay-requester": "NONE" }, "ef972f063451539e8b2ad88e831d87b6": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923434, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Dryer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627513, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Dryer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", "info/name": "Electric Dryer", "info/spaces": "20,22", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-5000.0", - "meter/current": "20.833333333333332", + "meter/active-power": "0.0", + "meter/current": "0.0", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -531,15 +531,15 @@ "switch/relay-requester": "NONE" }, "f515a0f43b6555b1a196fbb62728c24e": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923431, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Exterior Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627510, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Exterior Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Exterior Lights", "info/spaces": "6", "load-shed/priority": "OFF_GRID", - "meter/active-power": "0.0", - "meter/current": "0.0", + "meter/active-power": "-62.821952544424036", + "meter/current": "0.5235162712035336", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -549,7 +549,7 @@ "switch/relay-requester": "NONE" }, "sim-40t-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923436, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Span Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"sim-40t-001-SIM-BESS-40T-001\", \"770e2de52c33508a8a9ee8878064b46f\", \"9429f828509e58d59cb5f0f9f5fee523\", \"3d9d86f303cc50d1827be57d4c667e53\", \"c058aa11287f50f9b81e5160a0678869\", \"f515a0f43b6555b1a196fbb62728c24e\", \"3eeb0eb1605e5a7eadac41994b7a096c\", \"e0ac90e169e6550ea83fe0b1942f1d0e\", \"80a4fada833156ab8112f9d50e252b8f\", \"13044bfbcbe5554b8f3dba126bce828f\", \"b24483358d29589d8e91d3bf11113269\", \"d1ff145887a05b839ede89409c27b398\", \"edee3425d50d51ffb022ee999053b2b4\", \"c339ec7ce7ff521ca7646f9606baff9f\", \"2140a7e253ed54e3bc90a959081df615\", \"4d1deb6acb065746b13207b1358f8ca7\", \"43a0521737db516f99f14a9964ea4af0\", \"e0bc156c85015a609d4132084dfcd6fe\", \"afe90839f2725e3e962fb05afa2b6d43\", \"4aeb08c46c2c5905a944166413f2f1ef\", \"516694a326a35cd88600b3520e8a981a\", \"1eeeb748eeaa58edb7e9b7e9dbbdeca7\", \"ef972f063451539e8b2ad88e831d87b6\", \"af731c49a6785a4cb2ea5549fb8bce7e\", \"948dea7788aa5c959b99df0edfabead2\", \"be7742043a06554aab2a1e38cc776603\", \"4ce8b30e8d3f5c49b9e0ab0c8caf4832\", \"249a2f59782e5f1ab317c4632e79afad\", \"1bfdc7ecebb0547bbe87a3696cddb0c0\", \"6fcb352679ad5bfb8c8a8eab06829b9f\", \"b9fa08f1eaaf5d129bd5c78e1d5d937f\", \"sim-40t-001-SIM-EVSE-sim-40t-001\", \"sim-40t-001-SIM-EVSE-sim-40t-001-2\", \"sim-40t-001-lugs-up\", \"sim-40t-001-lugs-dn\", \"sim-40t-001-pv-1\"], \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627515, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Span Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"sim-40t-001-SIM-BESS-40T-001\", \"770e2de52c33508a8a9ee8878064b46f\", \"9429f828509e58d59cb5f0f9f5fee523\", \"3d9d86f303cc50d1827be57d4c667e53\", \"c058aa11287f50f9b81e5160a0678869\", \"f515a0f43b6555b1a196fbb62728c24e\", \"3eeb0eb1605e5a7eadac41994b7a096c\", \"e0ac90e169e6550ea83fe0b1942f1d0e\", \"80a4fada833156ab8112f9d50e252b8f\", \"13044bfbcbe5554b8f3dba126bce828f\", \"b24483358d29589d8e91d3bf11113269\", \"d1ff145887a05b839ede89409c27b398\", \"edee3425d50d51ffb022ee999053b2b4\", \"c339ec7ce7ff521ca7646f9606baff9f\", \"2140a7e253ed54e3bc90a959081df615\", \"4d1deb6acb065746b13207b1358f8ca7\", \"43a0521737db516f99f14a9964ea4af0\", \"e0bc156c85015a609d4132084dfcd6fe\", \"afe90839f2725e3e962fb05afa2b6d43\", \"4aeb08c46c2c5905a944166413f2f1ef\", \"516694a326a35cd88600b3520e8a981a\", \"1eeeb748eeaa58edb7e9b7e9dbbdeca7\", \"ef972f063451539e8b2ad88e831d87b6\", \"af731c49a6785a4cb2ea5549fb8bce7e\", \"948dea7788aa5c959b99df0edfabead2\", \"be7742043a06554aab2a1e38cc776603\", \"4ce8b30e8d3f5c49b9e0ab0c8caf4832\", \"249a2f59782e5f1ab317c4632e79afad\", \"1bfdc7ecebb0547bbe87a3696cddb0c0\", \"6fcb352679ad5bfb8c8a8eab06829b9f\", \"b9fa08f1eaaf5d129bd5c78e1d5d937f\", \"sim-40t-001-SIM-EVSE-sim-40t-001\", \"sim-40t-001-SIM-EVSE-sim-40t-001-2\", \"sim-40t-001-lugs-up\", \"sim-40t-001-lugs-dn\", \"sim-40t-001-pv-1\"], \"extensions\": []}", "$state": "ready", "breaker/rating": "200", "door/state": "CLOSED", @@ -578,9 +578,9 @@ "pcs/requested-import-limit-active": "false", "pcs/requested-import-limit-enablement": "UNCONFIGURED", "power-flows/battery": "3500.0", - "power-flows/grid": "3477.2209580173558", - "power-flows/pv": "7327.47708818823", - "power-flows/site": "14304.698046205585", + "power-flows/grid": "3839.544028005632", + "power-flows/pv": "0", + "power-flows/site": "7339.544028005632", "shed-forecast/confidence": "HIGH", "shed-forecast/full-charge-time-to-priority-shed": "3038", "shed-forecast/full-charge-total-time-remaining": "4320", @@ -596,8 +596,9 @@ "status/wifi": "true" }, "sim-40t-001-SIM-BESS-40T-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923436, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"sim-40t-001-SIM-BESS-40T-001-mid\"], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627515, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"sim-40t-001-SIM-BESS-40T-001-mid\"], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", + "info/firmware-version": "sim-bess/v0.1.0", "info/model": "SPAN Battery", "info/nameplate-capacity": "13.5", "info/part-number": "SPN-BESS-001", @@ -609,7 +610,7 @@ "status/communication-state": "OK" }, "sim-40t-001-SIM-BESS-40T-001-mid": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923436, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001-SIM-BESS-40T-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627515, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001-SIM-BESS-40T-001\", \"extensions\": []}", "$state": "ready", "grid/grid-forming-entity": "GRID", "grid/grid-state": "UP", @@ -618,7 +619,7 @@ "info/vendor-name": "Span" }, "sim-40t-001-SIM-EVSE-sim-40t-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923436, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627514, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "config/max-charge-current": "32", "config/user-max-charge-current": "32", @@ -632,7 +633,7 @@ "switch/lock-state": "UNLOCKED" }, "sim-40t-001-SIM-EVSE-sim-40t-001-2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923436, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627515, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "config/max-charge-current": "32", "config/user-max-charge-current": "32", @@ -646,7 +647,7 @@ "switch/lock-state": "UNLOCKED" }, "sim-40t-001-lugs-dn": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923436, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627515, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "info/direction": "DOWNSTREAM", "meter/active-power": "0", @@ -656,20 +657,20 @@ "meter/imported-energy": "0" }, "sim-40t-001-lugs-up": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923436, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627515, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "connection/fed-by-device-id": "sim-40t-001-SIM-BESS-40T-001", "connection/fed-by-device-status": "OK", "connection/fed-by-device-type": "energy.ebus.device.bess", "info/direction": "UPSTREAM", - "meter/active-power": "6977.220958017356", - "meter/current-a": "99.37672150823833", - "meter/current-b": "80.89140461171012", + "meter/active-power": "7339.544028005632", + "meter/current-a": "32.273652491578986", + "meter/current-b": "28.889214408467947", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0" }, "sim-40t-001-pv-1": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786400923436, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1786424627515, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "info/model": "IQ8PLUS-72-2-US", "info/nominal-power": "10000.0", diff --git a/packages/schema-1/src/span_panel_api_schema_1/devices.py b/packages/schema-1/src/span_panel_api_schema_1/devices.py index eed2bda..4ecfa56 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/devices.py +++ b/packages/schema-1/src/span_panel_api_schema_1/devices.py @@ -122,7 +122,12 @@ def build_battery(bess: DiscoveredDevice | None, owners: list[DiscoveredDevice]) ) -def build_pv(pv: DiscoveredDevice | None, feeds: dict[str, str]) -> SpanPVSnapshot: +def build_pv( + pv: DiscoveredDevice | None, + feeds: dict[str, str], + upstream_lugs: DiscoveredDevice | None = None, + downstream_lugs: DiscoveredDevice | None = None, +) -> SpanPVSnapshot: """Build the PV snapshot. An uncommissioned panel yields the empty one.""" if pv is None: return SpanPVSnapshot() @@ -132,11 +137,10 @@ def build_pv(pv: DiscoveredDevice | None, feeds: dict[str, str]) -> SpanPVSnapsh model=_optional(text(pv, NODE_INFO, PROP_MODEL)), nameplate_capacity_w=number(pv, NODE_INFO, PROP_NOMINAL_POWER), feed_circuit_id=feeds.get(pv.device_id), - # `relative-position` is retired in v1.0 and the guide is explicit that - # it is only "derivable from connection records (when present)". Left - # None rather than guessed: the integration gates control entities on - # it, so a wrong value creates or removes a control. - relative_position=None, + # Retired as a property in v1.0 and derived instead, per the enclosure model's + # own replacement rule. `None` where no owner references the DER, because the + # integration gates whether a control entity exists on this value. + relative_position=resolve_relative_position(pv.device_id, feeds, upstream_lugs, downstream_lugs), ) @@ -193,3 +197,49 @@ def build_mid(mid: DiscoveredDevice | None, device_names: Mapping[str, str]) -> grid_forming_entity=_optional(text(mid, NODE_GRID, PROP_GRID_FORMING_ENTITY)), grid_forming_device_name=resolve_grid_forming_device_name(mid, device_names), ) + + +POSITION_IN_PANEL = "IN_PANEL" +POSITION_UPSTREAM = "UPSTREAM" + + +def resolve_relative_position( + device_id: str, + feeds: dict[str, str], + upstream_lugs: DiscoveredDevice | None, + downstream_lugs: DiscoveredDevice | None, +) -> str | None: + """Where a DER sits relative to the enclosure, from the connection records. + + v1.0 removed `relative-position` as a property deliberately, and the enclosure model + says what replaces it: "The position of a DER relative to the enclosure is derivable + from which enclosure-side connection-owner references the DER." + + | owner referencing the DER | position | + | --- | --- | + | a circuit's `connection/feeds-device-id` | `IN_PANEL` | + | the downstream lugs' `connection/feeds-device-id` | `IN_PANEL`, via feedthrough | + | the upstream lugs' `connection/fed-by-device-id` | `UPSTREAM` | + | nothing | `None` — not commissioned to this enclosure, or not yet announced | + + Verified against the paired captures rather than reasoned: flat publishes + `pv/relative-position = IN_PANEL` and `bess/relative-position = UPSTREAM`, and this + derives exactly those from a circuit feeding the PV and the upstream lugs being fed + by the BESS. + + `None`, not a guess. The integration gates whether a *control entity exists at all* + on this value, so inventing one creates or removes a control. The guide is explicit + that where no owner references the DER, position is not derivable. + + The feedthrough branch is unreachable against every producer available today: no lugs + device can publish `connection/feeds-*` at all, which is + electrification-bus/distribution-enclosure-simulator#30. It is written because the + rule has three cases and omitting one would read as a claim that it cannot happen. + """ + if device_id in feeds: + return POSITION_IN_PANEL + if downstream_lugs is not None and text(downstream_lugs, NODE_CONNECTION, PROP_FEEDS_DEVICE_ID) == device_id: + return POSITION_IN_PANEL + if upstream_lugs is not None and text(upstream_lugs, NODE_CONNECTION, PROP_FED_BY_DEVICE_ID) == device_id: + return POSITION_UPSTREAM + return None diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index 6fdfc0a..c1be8ad 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -95,6 +95,7 @@ (TYPE_BESS, NODE_INFO, "model", "battery.model"), (TYPE_BESS, NODE_INFO, "part-number", "battery.part_number"), (TYPE_BESS, NODE_INFO, "serial-number", "battery.serial_number"), + (TYPE_BESS, NODE_INFO, "firmware-version", "battery.software_version"), (TYPE_BESS, NODE_INFO, "nameplate-capacity", "battery.nameplate_capacity_kwh"), # --- PV ------------------------------------------------------------------ (TYPE_PV, NODE_INFO, "vendor-name", "pv.vendor_name"), diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index cdb6e52..007ec63 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -178,7 +178,7 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re downstream_l2_current_a=fields.downstream_l2_current_a, circuits=circuits, battery=build_battery(roles.bess, owners), - pv=build_pv(roles.pv, feeds), + pv=build_pv(roles.pv, feeds, upstream, downstream), mid=build_mid(roles.mid, device_names), evse={key: build_evse(device, feeds, node_id=key) for device, key in _harmonised_evse_keys(roles.evse).items()}, ) diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json index 5c1053d..bd75aed 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -14,7 +14,7 @@ "repo": "https://github.com/SpanPanel/panelbench", "ref": "feat/adopt-upstream-emitter", "role": "publisher", - "commit": "c83c56cced43e27eaf1c2e6f436b56239cde0b42", + "commit": "0a867c3550b2b9f00a4e2e36bbf39018d7870489", "synced_commit": "4254526b0a8c11cab0d40fd700b1fc295c0479c6", "firmware_range": "r202633+", "fixtures": { diff --git a/tests/test_schema_migration_delta.py b/tests/test_schema_migration_delta.py index e85ab30..eb4d363 100644 --- a/tests/test_schema_migration_delta.py +++ b/tests/test_schema_migration_delta.py @@ -113,6 +113,13 @@ _SERIAL = "sim-40t-001" EXPECTED_ORPHANS: dict[str, str] = { + # `pv.relative_position` was here until 2026-08-10, and it was right that closing it + # was cheap. v1.0 retired the property deliberately -- the enclosure model says the + # position "is derivable from which enclosure-side connection-owner references the + # DER" -- so `resolve_relative_position` reads the connection records instead. + # Verified against the pair: flat says IN_PANEL for PV and UPSTREAM for the BESS, and + # the derivation produces exactly those. + # # `panel.dominant_power_source` was here until 2026-08-10. It is populated now: the # integration's entity for it is already named `grid_forming_entity`, so v1.0's # `grid/grid-forming-entity` is the same concept, and dereferencing the device id @@ -123,10 +130,6 @@ "no v1.0 source; the flat panel advertised islandability as a panel property and " "the redesign expresses it through the presence of a MID instead" ), - "pv.relative_position": ( - "flat publishes pv/relative-position; schema_1 does not map the v1.0 equivalent yet. " - "Unlike the two above this is a gap rather than a decision, and closing it is cheap" - ), } EXPECTED_DEGRADED: dict[str, str] = {} @@ -166,6 +169,7 @@ "battery.model", "battery.part_number", "battery.serial_number", + "battery.software_version", } ) """Additions that may not be additions, because the flat reference never sends them. diff --git a/tests/test_schema_one_adapter.py b/tests/test_schema_one_adapter.py index 2c0e3cb..24516cd 100644 --- a/tests/test_schema_one_adapter.py +++ b/tests/test_schema_one_adapter.py @@ -295,31 +295,30 @@ def test_the_upstream_lugs_fields_are_not_displaced(adapter: SchemaOneAdapter) - assert metadata["panel.instant_grid_power_w"].unit == "W" -def test_the_bess_serial_is_described_and_its_firmware_is_not(adapter: SchemaOneAdapter) -> None: - """Class B of the survival analysis, which was misdiagnosed. - - It recorded `battery.serial_number` and `battery.software_version` as having - "no mapping at all… simply never picked up". `build_battery` has always read - both. What was missing was the *value*: the producer of the day published no - BESS identity, so both read `None`, and an unpopulated field was mistaken for - an unmapped one. - - So the gap was a metadata row, not a mapping. `serial_number` gets one — the - BESS declares it and now publishes it. - - `software_version` deliberately does not. The BESS declares - `info/firmware-version` and never sends a value, which is the residual half of - §5.2, and describing it would advertise a reading that never arrives — the - exact failure the metadata builder's own docstring refuses to commit. It stays - absent until the producer publishes it, and - `test_the_ders_still_declare_two_identity_fields_they_never_publish` fails when - that changes. +def test_both_bess_identity_fields_are_described(adapter: SchemaOneAdapter) -> None: + """Class B of the survival analysis, which was misdiagnosed and is now closed. + + It recorded `battery.serial_number` and `battery.software_version` as having "no + mapping at all… simply never picked up". `build_battery` has always read both. What + was missing was the *value*: the producer published no BESS identity, so both read + `None`, and an unpopulated field was mistaken for an unmapped one. + + `serial_number` got its row when the producer started publishing `info/serial-number`. + `software_version` was deliberately withheld while the BESS declared + `info/firmware-version` and never sent it — describing it would have advertised a + reading that never arrives, the one thing the metadata builder's docstring refuses. + + panelbench now supplies a placeholder firmware version, so the declaration is no + longer empty and the row is honest. Synthetic: it attests the mapping, not what real + firmware sends, which the delta document records rather than letting a config change + launder into a fidelity claim. """ metadata = adapter.build_field_metadata() assert metadata["battery.serial_number"].datatype == "string" assert metadata["battery.serial_number"].unit is None - assert "battery.software_version" not in metadata + assert metadata["battery.software_version"].datatype == "string" + assert metadata["battery.software_version"].unit is None def test_the_downstream_fields_need_a_downstream_device() -> None: diff --git a/tests/test_schema_one_against_simulator.py b/tests/test_schema_one_against_simulator.py index 5a75552..0673240 100644 --- a/tests/test_schema_one_against_simulator.py +++ b/tests/test_schema_one_against_simulator.py @@ -176,7 +176,15 @@ def test_the_ders_still_declare_two_identity_fields_they_never_publish() -> None ) assert gaps == { - "energy.ebus.device.bess": ["info/firmware-version"], + # The BESS pair closed on 2026-08-10: panelbench now supplies a placeholder + # `firmware_version`, so the declaration stops being empty and the mapping + # downstream stops being untestable. Synthetic, so it attests the mapping and + # not what real firmware sends. + # + # PV keeps both deliberately. This check is doing real work while it is + # non-empty, and filling every gap with invented values would retire the signal + # without making anything more true -- the BESS one was filled because a + # mapping was blocked on it, and these block nothing. "energy.ebus.device.pv": ["info/firmware-version", "info/serial-number"], }, f"the declared-but-unpublished set moved: {gaps}" From aeed11f8b02ede251e8f09f18556f710f5414707 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:11:38 -0700 Subject: [PATCH 066/115] chore(schema_1): point the peer record at panelbench on emitter 0.5.0 peer.commit 0a867c3 -> 2d8234f. The captures themselves are unchanged: 0.5.0 altered no profile and no datatype, so the byte comparison the peer contract makes on the tree still holds and only the recorded provenance moves. 607 passed. --- packages/schema-1/src/span_panel_api_schema_1/spec_lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json index bd75aed..32cfb89 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -14,7 +14,7 @@ "repo": "https://github.com/SpanPanel/panelbench", "ref": "feat/adopt-upstream-emitter", "role": "publisher", - "commit": "0a867c3550b2b9f00a4e2e36bbf39018d7870489", + "commit": "2d8234ffd42dcf78e5cbfafc6ee8a694db45f151", "synced_commit": "4254526b0a8c11cab0d40fd700b1fc295c0479c6", "firmware_range": "r202633+", "fixtures": { From e04bbea77f258663d5432a66fe0e5093142ebb86 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:32:53 -0700 Subject: [PATCH 067/115] Reconsider the parser when a panel changes schema generation mid-session The adapter was chosen once, at connect, and nothing ever revisited it. `connect()` short-circuits on the cached schema, the reconnect path re-subscribes with the existing adapter's topics, and the pre-rebuild hook rebuilds from the cache on a stated assumption: "the Homie schema cannot change within a session". A firmware upgrade is that assumption failing. The panel disconnects and returns as a different generation with the consumer's session still open, so nothing reconsiders. Observed live: a flat panel upgraded to v1.0 under a running Home Assistant, the client reconnected, kept the flat parser, and read the v1.0 tree with it. One `Invalid $description JSON`, then every circuit reported missing -- a wrong answer rather than an error. **The trigger is the MQTT property, not the reconnect edge.** Triggering on the edge was the first attempt and it does not work: the edge fires the moment the broker accepts a connection, which precedes the panel binding its HTTP port. It failed with `Cannot reach panel` 25ms after reconnect, and because MQTT had reconnected successfully there was no later edge to retry on -- one missed fetch left the wrong parser for the rest of the session. The retained `info/data-model-version` arrives only once the new panel is publishing, so it is the first moment the answer exists. The fetch is also retried, because HTTP genuinely trails the broker on a restart and a single attempt at the worst possible moment is not a design. Compared by the adapter each version selects rather than by string, so `1.0` and `1.0.3` do not churn the parser on a patch release. Gated by a comparison before anything is scheduled, because the property republishes on every retained replay. `register_schema_change_callback` tells a consumer after the swap. Rebuilding the parser restores *reading*; it cannot restore *topology*, because devices and entities were built from the old tree -- v1.0 adds a MID the flat tree has no equivalent for and re-keys the EVSEs. Only the consumer knows how to rebuild that. Also lands the REST/MQTT cross-check, which shares the same observation. The migration guide has one rule on two transports, "exactly mirroring" each other, and nothing compared them: a producer publishing one and omitting the other dispatched on the half it found and reported a clean connection. It refuses on disagreement, following the rule dispatch already applies to an unparseable version -- the blast radius is every value in the tree. One bug found while testing: `_build_adapter` only records `_data_model_version` on the dispatching path, so a client built with an injected `adapter_factory` kept reporting the generation it started with after being rebuilt for a different one. 15 tests; 622 passed; mypy clean; ruff clean. --- pyproject.toml | 4 + src/span_panel_api/mqtt/client.py | 288 ++++++++++++++++++- tests/test_redispatch_on_reconnect.py | 301 ++++++++++++++++++++ tests/test_schema_generation_cross_check.py | 119 ++++++++ tests/test_schema_one_conformance.py | 23 +- 5 files changed, 727 insertions(+), 8 deletions(-) create mode 100644 tests/test_redispatch_on_reconnect.py create mode 100644 tests/test_schema_generation_cross_check.py diff --git a/pyproject.toml b/pyproject.toml index 331399a..f61dae6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -215,6 +215,10 @@ disable = [ "missing-class-docstring", "missing-function-docstring", "too-few-public-methods", + # The transport implements four protocols, so its public surface is set by + # how many the composition asks for rather than by anything a split would + # improve. Every sibling in this family is already off for the same reason. + "too-many-public-methods", "too-many-arguments", "too-many-instance-attributes", "too-many-locals", diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 5667776..e4fedae 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -19,7 +19,13 @@ from ..adapters import discover_adapters, resolve_adapter from ..auth import get_homie_schema from ..dispatch import select_adapter_key -from ..exceptions import SpanPanelConnectionError, SpanPanelServerError, SpanPanelStaleDataError +from ..exceptions import ( + SpanPanelConnectionError, + SpanPanelSchemaVersionError, + SpanPanelServerError, + SpanPanelStaleDataError, + SpanPanelTimeoutError, +) from ..models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot, V2HomieSchema from ..protocol import PanelCapability, SchemaAdapter from .connection import AsyncMqttBridge @@ -33,6 +39,13 @@ _CIRCUIT_NAMES_TIMEOUT_S = 10.0 _CIRCUIT_NAMES_POLL_INTERVAL_S = 0.25 +# Re-reading the schema after a suspected generation change. Bounded because the +# caller is a fire-and-forget task on a live connection, and generous enough to +# outlast a panel that is still binding its HTTP port after a restart. +_REDISPATCH_RETRY_ATTEMPTS = 5 +_REDISPATCH_RETRY_INITIAL_S = 1.0 +_REDISPATCH_RETRY_MAX_S = 8.0 + class SpanMqttClient: """MQTT transport — implements all span-panel-api protocols.""" @@ -61,6 +74,7 @@ def __init__( self._streaming = False self._snapshot_callbacks: list[Callable[[SpanPanelSnapshot], Awaitable[None]]] = [] self._connection_callbacks: list[Callable[[bool], None]] = [] + self._schema_change_callbacks: list[Callable[[str | None, str | None], None]] = [] self._live = False self._ready_event: asyncio.Event | None = None self._loop: asyncio.AbstractEventLoop | None = None @@ -72,8 +86,14 @@ def __init__( # Supplied by create_span_client, which already fetched it to dispatch # on; None when constructed directly, in which case connect() fetches. # Either way it is cached for the pre-rebuild hook, which rebuilds the - # parser after a transport-level rebuild. A panel cannot change schema - # within a session, so caching is safe. + # parser after a transport-level rebuild. + # + # The cache used to be justified as "a panel cannot change schema within a + # session". A firmware upgrade does exactly that: the panel disconnects and + # returns as a different generation with the consumer's session still open. + # `_redispatch_if_generation_changed` re-reads it on every reconnect edge and + # replaces this, so the cache is now a per-connection value rather than a + # per-session one. self._schema = schema # Diagnostics. create_span_client passes these so they are true from the # first moment the object exists; constructing directly leaves them @@ -81,6 +101,13 @@ def __init__( # fills in once it has a schema to dispatch on. self._data_model_version = data_model_version self._schema_dispatch_reason = schema_dispatch_reason or "not dispatched" + # The MQTT half of the same signal, filled in as the retained tree arrives and + # checked against the REST half once the tree is complete. `None` means the + # panel published no such property, which is itself the flat answer. + self._observed_data_model_version: str | None = None + # One reconsideration at a time. The MQTT trigger can fire repeatedly while a + # fetch is retrying, and each would otherwise start its own retry loop. + self._redispatch_in_flight = False def _build_adapter(self, schema: V2HomieSchema) -> SchemaAdapter: """Construct the parser for this session. @@ -285,8 +312,59 @@ async def connect(self) -> None: # may arrive after $state=ready). Without this, the first snapshot # has empty circuit names and entities are created without labels. await self._wait_for_circuit_names(timeout=_CIRCUIT_NAMES_TIMEOUT_S) + + self._assert_transports_agree_on_schema_generation() _LOGGER.debug("MQTT: Connection fully established") + def _assert_transports_agree_on_schema_generation(self) -> None: + """Refuse a panel whose two schema-generation signals disagree. + + The migration guide's "Schema-generation detection" carries one rule on two + transports: MQTT ``info/data-model-version`` absent = flat, present = + parent/child; REST ``dataModelVersion`` absent = flat, exactly mirroring the + MQTT signal. Dispatch reads REST, because the adapter decides which topics to + subscribe to and so must exist before the first SUBSCRIBE. That makes the MQTT + value a free second opinion, and until now nothing looked at it. + + Nothing looking at it is how a v1.0 panel gets parsed by the flat adapter in + silence: a producer that publishes the MQTT property but omits the REST one + dispatches to ``schema_0``, every value in the tree is read against the wrong + vocabulary, and the connection reports success. Wrong numbers, no error. + + Raising rather than warning follows the rule dispatch already applies to an + unparseable version: an unknown schema generation means every value in the + tree may be misread, and the blast radius is the whole panel. A disagreement + is that same situation with a second witness. + + Compared by the adapter each value *selects*, not by string equality -- + ``'1.0'`` and ``'1.0.3'`` are both parsed by ``schema_1``, and failing that + pair would be a false alarm about a patch release. + """ + observed = self._observed_data_model_version + reported = self._data_model_version + try: + observed_key, _ = select_adapter_key(observed) + reported_key, _ = select_adapter_key(reported) + except SpanPanelSchemaVersionError: + # One of them is present but unparseable. Dispatch already refused on the + # REST value before we got here, so this is the MQTT one -- report it as + # the disagreement it is rather than re-raising a message about REST. + raise SpanPanelSchemaVersionError( + f"Panel {self._serial_number} publishes MQTT info/data-model-version=" + f"{observed!r}, which no adapter major can be read from, while REST " + f"reports dataModelVersion={reported!r}" + ) from None + + if observed_key != reported_key: + raise SpanPanelSchemaVersionError( + f"Panel {self._serial_number} disagrees with itself about its schema " + f"generation: REST dataModelVersion={reported!r} selects " + f"{reported_key!r}, MQTT info/data-model-version={observed!r} selects " + f"{observed_key!r}. The migration guide requires the two to mirror each " + f"other; parsing the tree with either parser would misread values the " + f"other owns." + ) + async def close(self) -> None: """Disconnect from broker and clean up.""" self._streaming = False @@ -324,6 +402,29 @@ def unregister() -> None: return unregister + def register_schema_change_callback(self, callback: Callable[[str | None, str | None], None]) -> Callable[[], None]: + """Subscribe to the panel changing schema generation mid-session. + + Fires with ``(previous_version, new_version)`` after the parser has been + rebuilt, so a consumer reading the client inside the callback sees the new + generation rather than the one being replaced. + + This exists because swapping the parser is not the whole job. It fixes + *reading* — values resolve again immediately — but a consumer that built + devices and entities from the old tree still has the old topology: v1.0 adds + a MID that the flat tree has no equivalent for, and re-keys EVSEs. Only the + consumer knows how to rebuild that, so it is told rather than guessed at. + + Returns an unregister function. Calling it twice is safe. + """ + self._schema_change_callbacks.append(callback) + + def unregister() -> None: + with contextlib.suppress(ValueError): + self._schema_change_callbacks.remove(callback) + + return unregister + async def get_snapshot(self) -> SpanPanelSnapshot: """Return current snapshot from accumulated MQTT state. @@ -424,9 +525,30 @@ async def stop_streaming(self) -> None: def _on_message(self, topic: str, payload: str) -> None: """Handle incoming MQTT message (called from asyncio loop).""" + # The bootstrap signal, observed rather than parsed, and deliberately ahead of + # the adapter guard: reading it is what tells the generations apart, so it + # cannot be something only a chosen parser can do. + # + # Matched on suffix so no Homie domain constant has to exist in the transport. + # Only the root device's copy counts -- under parent/child every device has an + # `info` node, and a child's copy would otherwise overwrite the panel's answer + # depending on retained-message ordering. + if topic.endswith(f"/{self._serial_number}/info/data-model-version"): + self._observed_data_model_version = payload or None + # This is the trigger for a mid-session generation change, not the + # reconnect edge. The edge fires the instant the broker accepts a + # connection, which on a real upgrade is *before* the panel has bound + # its HTTP port -- observed as `Cannot reach panel` roughly 25ms after + # reconnect, with no further edge to retry on because MQTT had already + # succeeded. The retained tree arrives only once the new panel is + # actually publishing, which makes this the first moment the answer + # exists at all. + self._schedule_redispatch() + adapter = self._adapter if adapter is None: return + was_ready = adapter.is_ready() adapter.handle_message(topic, payload) @@ -463,6 +585,12 @@ def _on_connection_change(self, connected: bool) -> None: if self._bridge is not None and self._adapter is not None: for topic in self._adapter.topics_to_subscribe(): self._bridge.subscribe(topic, qos=0) + # A reconnect can be a different panel generation than the one we + # dispatched on. Checked only on a real edge, because paho re-emits + # connected=True after session restoration and refetching the schema + # on each of those would be a HTTP round trip per duplicate. + if not self._live: + self._schedule_redispatch() else: _LOGGER.debug("MQTT connection lost") # Cancel any pending snapshot-debounce timer so it cannot @@ -481,6 +609,160 @@ def _on_connection_change(self, connected: bool) -> None: except Exception: # pylint: disable=broad-exception-caught _LOGGER.warning("Connection callback raised", exc_info=True) + def _schedule_redispatch(self) -> None: + """Reconsider the panel's schema generation, off the calling callback. + + Both callers are synchronous — the connection-change handler and the message + handler — and the work is a HTTP round trip, so it is handed to the loop. + + Cheap to call often: the MQTT trigger fires on every retained + `info/data-model-version`, and the common case is that it agrees with the + active adapter. That is answered here without scheduling anything, so a + steady-state panel costs one string comparison per republish. + """ + if self._loop is None or self._adapter is None: + # No loop means connect() never ran, so there is nothing dispatched to + # reconsider and no loop to schedule the reconsideration on. + return + if self._redispatch_in_flight: + return + if not self._generation_appears_changed(): + return + self._redispatch_in_flight = True + task = self._loop.create_task(self._redispatch_if_generation_changed()) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + def _generation_appears_changed(self) -> bool: + """Whether any signal we hold suggests a parser other than the active one. + + Deliberately permissive: it gates scheduling, not the swap itself. The + authoritative comparison happens in `_redispatch_if_generation_changed` + against a freshly fetched REST schema, so a false positive here costs one + HTTP request and a false negative costs a missed upgrade. + """ + try: + active, _ = select_adapter_key(self._data_model_version) + observed, _ = select_adapter_key(self._observed_data_model_version) + except SpanPanelSchemaVersionError: + # Unreadable version. Let the full path report it properly. + return True + return active != observed + + async def _fetch_schema_with_retry(self) -> V2HomieSchema | None: + """Read the panel's REST schema, allowing for HTTP trailing the broker. + + A panel that has just restarted accepts MQTT before it serves HTTP — the + broker is listening while the application is still binding its port. The + first attempt at a real upgrade failed 25ms after reconnect with + `Cannot reach panel`, and because MQTT had reconnected successfully there + was no further edge to retry on, leaving the wrong parser in place for the + rest of the session. + + So this waits, briefly and boundedly. Returning None rather than raising + because the caller's job is to reconsider the parser, and being unable to + is not a reason to disturb a connection that is otherwise working. + """ + delay = _REDISPATCH_RETRY_INITIAL_S + last: Exception | None = None + for _ in range(_REDISPATCH_RETRY_ATTEMPTS): + try: + return await get_homie_schema(self._host, port=self._panel_http_port) + except (SpanPanelConnectionError, SpanPanelTimeoutError) as exc: + last = exc + await asyncio.sleep(delay) + delay = min(delay * 2, _REDISPATCH_RETRY_MAX_S) + _LOGGER.warning( + "Could not re-read the panel schema after %d attempts (%s). The active " + "parser is unchanged; if the panel's schema generation did change, its " + "data will read as missing until the next reconnect.", + _REDISPATCH_RETRY_ATTEMPTS, + last, + ) + return None + + async def _redispatch_if_generation_changed(self) -> None: + """Swap the parser when the panel comes back as a different schema generation. + + The adapter is chosen once, at connect, from the REST `dataModelVersion`. + Everything after that reuses it: `connect()` short-circuits on the cached + `self._schema`, the reconnect path re-subscribes with the existing adapter's + topics, and `_on_pre_rebuild` rebuilds from the cached schema on the stated + assumption that "the Homie schema cannot change within a session". + + A firmware upgrade breaks that assumption exactly. The panel disconnects and + returns as a different generation while the consumer's session is still open + — no reload, no new `connect()`, so nothing ever reconsiders. Observed as a + flat panel upgrading to v1.0 underneath a live client: the client reconnected, + kept the flat parser, and read the v1.0 tree with it. It logged one + `Invalid $description JSON` and then reported every circuit as missing, which + is a wrong answer rather than an error. + + Failure here is deliberately non-fatal. The panel is reachable over MQTT or + this callback would not be running, and its HTTP endpoint may lag that by + seconds while it finishes booting; treating a refused fetch as fatal would + turn a slow boot into a dead integration. The generation is re-read on the + next reconnect, and a stale parser reports missing data rather than wrong + data, because the two schemas do not share a topic shape. + """ + try: + schema = await self._fetch_schema_with_retry() + finally: + self._redispatch_in_flight = False + if schema is None: + return + + before = self._data_model_version + try: + new_key, _ = select_adapter_key(schema.data_model_version) + old_key, _ = select_adapter_key(before) + except SpanPanelSchemaVersionError: + _LOGGER.warning( + "Panel reports data-model-version %r after reconnect, which no adapter " + "major can be read from; keeping the %r parser", + schema.data_model_version, + before, + ) + return + + if new_key == old_key: + return + + _LOGGER.warning( + "Panel changed schema generation while connected: data-model-version %r -> " + "%r (%s -> %s). Rebuilding the parser; entities will repopulate from the " + "new tree.", + before, + schema.data_model_version, + old_key, + new_key, + ) + self._schema = schema + # Set here rather than relying on `_build_adapter`, which only records it on + # the dispatching path. A client constructed with an injected `adapter_factory` + # skips that branch, and would go on reporting the generation it started with + # after having been rebuilt for a different one. + self._data_model_version = schema.data_model_version + adapter = self._build_adapter(schema) + self._field_metadata = adapter.build_field_metadata() + # Ready is a property of the tree, and this is a different tree. Leaving the + # old event set would let `is_ready()` answer for a parser that has not seen + # a single message yet. + self._ready_event = asyncio.Event() + if self._bridge is not None: + for topic in adapter.topics_to_subscribe(): + self._bridge.subscribe(topic, qos=0) + + # Announced after the swap, so a consumer inspecting the client from inside + # the callback sees the generation it is being told about. Iterate a copy — + # a subscriber may unregister while handling this, and reloading a config + # entry (the expected response) tears down the very object that registered. + for cb in list(self._schema_change_callbacks): + try: + cb(before, schema.data_model_version) + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.warning("Schema-change callback raised", exc_info=True) + def _on_pre_rebuild(self) -> None: """Reset Homie accumulator state before the bridge rebuilds its paho client. diff --git a/tests/test_redispatch_on_reconnect.py b/tests/test_redispatch_on_reconnect.py new file mode 100644 index 0000000..a06749b --- /dev/null +++ b/tests/test_redispatch_on_reconnect.py @@ -0,0 +1,301 @@ +"""A panel that comes back as a different generation gets a different parser. + +The adapter is chosen once, at connect, from the REST `dataModelVersion`. Everything +afterwards reused it: `connect()` short-circuits on the cached schema, the reconnect +path re-subscribes with the existing adapter's topics, and the pre-rebuild hook +rebuilds from the cached schema on the stated assumption that "the Homie schema +cannot change within a session". + +A firmware upgrade is that assumption failing. The panel disconnects and returns as a +different generation while the session is still open, so nothing reconsiders. Seen +live: a flat panel upgraded to v1.0 underneath a running Home Assistant, the client +reconnected, kept the flat parser, and read the v1.0 tree with it. It logged a single +`Invalid $description JSON` and then reported every circuit as missing -- a wrong +answer rather than an error, which is the failure mode worth testing for. + +**The trigger is the MQTT property, not the reconnect edge.** Triggering on reconnect +was the first attempt and it does not work: the edge fires the moment the broker +accepts a connection, which on a real upgrade precedes the panel binding its HTTP +port. It failed with `Cannot reach panel` 25ms after reconnect, and since MQTT had +reconnected successfully there was no later edge to retry on -- the wrong parser +stayed for the rest of the session. The retained `info/data-model-version` arrives +only once the new panel is publishing, so it is the first moment the answer exists. +""" + +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import patch + +import pytest + +from span_panel_api.exceptions import SpanPanelConnectionError +from span_panel_api.mqtt.client import _REDISPATCH_RETRY_ATTEMPTS, SpanMqttClient +from span_panel_api.mqtt.models import MqttClientConfig + +from conftest import SERIAL + + +class _Schema: + """The slice of `V2HomieSchema` the dispatch path reads.""" + + def __init__(self, version: str | None) -> None: + self.data_model_version = version + self.types: dict[str, Any] = {} + self.types_schema_hash = f"sha256:{version}" + + +class _Adapter: + """Records which schema it was built from, so a swap is observable.""" + + def __init__(self, serial: str, schema: _Schema) -> None: + self.serial = serial + self.schema = schema + self.schema_major = f"schema_for_{schema.data_model_version}" + + def topics_to_subscribe(self) -> list[str]: + return [f"topics/for/{self.schema.data_model_version}"] + + def build_field_metadata(self) -> dict[str, Any]: + return {} + + def is_ready(self) -> bool: + return False + + def handle_message(self, topic: str, payload: str) -> None: + return None + + +class _Bridge: + def __init__(self) -> None: + self.subscribed: list[str] = [] + + def subscribe(self, topic: str, qos: int = 0) -> None: + self.subscribed.append(topic) + + +def _client(initial: str | None) -> tuple[SpanMqttClient, _Bridge]: + client = SpanMqttClient( + host="192.168.1.1", + serial_number=SERIAL, + broker_config=MqttClientConfig(broker_host="broker.local", username="u", password="p"), + adapter_factory=_Adapter, # type: ignore[arg-type] + data_model_version=initial, + ) + bridge = _Bridge() + client._bridge = bridge # type: ignore[assignment] + client._adapter = _Adapter(SERIAL, _Schema(initial)) # type: ignore[assignment] + client._loop = asyncio.get_running_loop() + return client, bridge + + +async def _panel_publishes_version(client: SpanMqttClient, version: str | None) -> None: + """Deliver the retained `info/data-model-version` the way the broker would.""" + client._on_message(f"ebus/5/{SERIAL}/info/data-model-version", version or "") + # The refetch is scheduled rather than awaited, so the message callback can stay + # synchronous. Let the loop drain it. + # One turn per retry attempt, plus slack for the task itself. + for _ in range(_REDISPATCH_RETRY_ATTEMPTS + 4): + await asyncio.sleep(0) + + +@pytest.mark.asyncio +async def test_a_generation_change_rebuilds_the_parser() -> None: + """The upgrade case: a flat panel starts publishing v1.0. + + Asserting the adapter *instance* changed and carries the new version, rather than + a log line -- the parser is what reads the tree, so it is the thing that has to + move. + """ + client, _ = _client(None) + before = client.adapter + + with patch("span_panel_api.mqtt.client.get_homie_schema", return_value=_Schema("1.0")): + await _panel_publishes_version(client, "1.0") + + assert client.adapter is not before, "the parser must be rebuilt, not reused" + assert client.data_model_version == "1.0" + + +@pytest.mark.asyncio +async def test_the_new_parsers_topics_are_subscribed() -> None: + """A new parser reading old topics would be a quieter version of the same bug. + + The two generations do not share a topic shape, so a rebuilt adapter that never + subscribes to its own topics receives nothing and reports an empty panel -- which + looks like a panel that has gone away rather than one that was mis-read. + """ + client, bridge = _client(None) + + with patch("span_panel_api.mqtt.client.get_homie_schema", return_value=_Schema("1.0")): + await _panel_publishes_version(client, "1.0") + + assert "topics/for/1.0" in bridge.subscribed + + +@pytest.mark.asyncio +async def test_an_unchanged_generation_never_fetches() -> None: + """Steady state must cost nothing. + + The panel republishes this property on every reconnect and on every retained + replay. Rebuilding — or even fetching — each time would discard accumulated tree + state and put avoidable load on the panel, so agreement is answered by comparison + alone, before anything is scheduled. + """ + client, _ = _client("1.0") + before = client.adapter + calls = 0 + + def _count(*_a: object, **_k: object) -> _Schema: + nonlocal calls + calls += 1 + return _Schema("1.0") + + with patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_count): + await _panel_publishes_version(client, "1.0") + await _panel_publishes_version(client, "1.0") + await _panel_publishes_version(client, "1.0") + + assert client.adapter is before + assert calls == 0, f"a matching generation must not be fetched, got {calls} fetches" + + +@pytest.mark.asyncio +async def test_a_patch_release_is_not_a_generation_change() -> None: + """`1.0` and `1.0.3` are read by the same parser. + + Comparing reported strings instead of the adapters they select would rebuild on + any patch release -- a pointless swap that drops tree state on a routine bump. + """ + client, _ = _client("1.0") + before = client.adapter + + with patch("span_panel_api.mqtt.client.get_homie_schema", return_value=_Schema("1.0.3")): + await _panel_publishes_version(client, "1.0.3") + + assert client.adapter is before + + +@pytest.mark.asyncio +async def test_http_lagging_the_broker_is_retried_not_abandoned() -> None: + """The failure that made the first attempt useless. + + A panel accepts MQTT before it serves HTTP: the broker is listening while the + application is still binding its port. The first fetch therefore fails, and + because MQTT reconnected *successfully* there is no later edge to retry on. One + attempt means the wrong parser stays for the rest of the session, which is exactly + what was observed live. + """ + client, _ = _client(None) + before = client.adapter + attempts = 0 + + def _lags_then_answers(*_a: object, **_k: object) -> _Schema: + nonlocal attempts + attempts += 1 + if attempts < 3: + raise SpanPanelConnectionError("Cannot reach panel") + return _Schema("1.0") + + with ( + patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_lags_then_answers), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_INITIAL_S", 0), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_MAX_S", 0), + ): + await _panel_publishes_version(client, "1.0") + + assert attempts >= 3, "the fetch must be retried while HTTP is still coming up" + assert client.adapter is not before, "the parser must swap once the fetch succeeds" + + +@pytest.mark.asyncio +async def test_a_panel_that_never_serves_http_leaves_the_parser_alone() -> None: + """Bounded, and non-fatal when the bound is reached. + + MQTT is up or this path would not be running, so tearing the connection down over + an unreachable HTTP endpoint would turn a degraded panel into a dead integration. + A stale parser reports missing data rather than wrong data, because the two + schemas share no topic shape. + """ + client, _ = _client(None) + before = client.adapter + + with ( + patch( + "span_panel_api.mqtt.client.get_homie_schema", + side_effect=SpanPanelConnectionError("never answers"), + ), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_INITIAL_S", 0), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_MAX_S", 0), + ): + await _panel_publishes_version(client, "1.0") + + assert client.adapter is before + assert not client._redispatch_in_flight, "the in-flight guard must clear on failure" + + +@pytest.mark.asyncio +async def test_a_consumer_is_told_the_generation_changed() -> None: + """Swapping the parser restores reading, not topology. + + A consumer builds its devices and entities from the tree as it looked at setup. + v1.0 introduces a MID the flat tree has no equivalent for and re-keys the EVSEs, + so a parser swap alone leaves the panel reading correctly while still showing the + old device set — observed live, where data flowed and a manual reload was still + needed. Only the consumer can rebuild that, so it is told rather than guessed at. + + Fired after the swap, so a consumer inspecting the client from inside the callback + sees the generation it is being told about rather than the one being replaced. + """ + client, _ = _client(None) + seen: list[tuple[str | None, str | None, str | None]] = [] + + client.register_schema_change_callback( + lambda previous, current: seen.append((previous, current, client.data_model_version)) + ) + + with patch("span_panel_api.mqtt.client.get_homie_schema", return_value=_Schema("1.0")): + await _panel_publishes_version(client, "1.0") + + assert seen == [(None, "1.0", "1.0")] + + +@pytest.mark.asyncio +async def test_an_unchanged_generation_tells_nobody() -> None: + """The callback reloads a config entry, so a spurious one is disruptive. + + This property republishes on every reconnect and retained replay. Firing on each + would reload the integration repeatedly, tearing down and rebuilding every entity + for a panel that never changed. + """ + client, _ = _client("1.0") + seen: list[tuple[str | None, str | None]] = [] + + client.register_schema_change_callback(lambda p, c: seen.append((p, c))) + + with patch("span_panel_api.mqtt.client.get_homie_schema", return_value=_Schema("1.0")): + await _panel_publishes_version(client, "1.0") + await _panel_publishes_version(client, "1.0") + + assert seen == [] + + +@pytest.mark.asyncio +async def test_a_raising_consumer_does_not_break_the_swap() -> None: + """The parser is already rebuilt when subscribers are told. + + Reloading a config entry tears down the object that registered the callback, so a + subscriber raising mid-teardown is a realistic outcome rather than a hypothetical + one. It must not leave the client half-swapped. + """ + client, _ = _client(None) + client.register_schema_change_callback(lambda _p, _c: (_ for _ in ()).throw(RuntimeError("boom"))) + reached: list[str] = [] + client.register_schema_change_callback(lambda _p, _c: reached.append("second")) + + with patch("span_panel_api.mqtt.client.get_homie_schema", return_value=_Schema("1.0")): + await _panel_publishes_version(client, "1.0") + + assert client.data_model_version == "1.0", "the swap must stand" + assert reached == ["second"], "one raising subscriber must not starve the others" diff --git a/tests/test_schema_generation_cross_check.py b/tests/test_schema_generation_cross_check.py new file mode 100644 index 0000000..dad45dc --- /dev/null +++ b/tests/test_schema_generation_cross_check.py @@ -0,0 +1,119 @@ +"""The two schema-generation signals must agree, and disagreement must be loud. + +The migration guide's "Schema-generation detection" carries one rule on two +transports: MQTT ``info/data-model-version`` absent = flat, present = parent/child; +REST ``dataModelVersion`` absent = flat, exactly mirroring the MQTT signal. + +Dispatch reads REST, because the adapter chooses which topics to subscribe to and so +must exist before the first SUBSCRIBE. That left the MQTT value unread, and a +producer that published one and not the other went undetected: the client dispatched +on the REST answer, parsed the tree with the wrong parser, and reported a clean +connection. Every number in Home Assistant was wrong and nothing said so. + +That is not hypothetical -- it is exactly what a parent/child simulator did when it +published `info/data-model-version` over MQTT while its REST schema omitted +`dataModelVersion`. These tests are what makes that state impossible to reach +quietly. +""" + +from __future__ import annotations + +import pytest + +from span_panel_api.exceptions import SpanPanelSchemaVersionError +from span_panel_api.mqtt.client import SpanMqttClient +from span_panel_api.mqtt.models import MqttClientConfig + +from conftest import SERIAL + + +def _client(*, reported: str | None, observed: str | None) -> SpanMqttClient: + """A client with the two signals set, without connecting to anything. + + The cross-check reads only these two values, so driving a whole connect flow to + reach it would test the mocking rather than the rule. + """ + client = SpanMqttClient( + host="192.168.1.1", + serial_number=SERIAL, + broker_config=MqttClientConfig(broker_host="broker.local", username="u", password="p"), + data_model_version=reported, + ) + client._observed_data_model_version = observed + return client + + +@pytest.mark.parametrize( + ("reported", "observed", "why"), + [ + (None, None, "flat panel: neither transport carries the property"), + ("1.0", "1.0", "parent/child panel: both carry the same value"), + ("1.0", "1.0.3", "same major, so the same parser reads both"), + ("1.2", "1.0", "same major across a minor bump"), + ], +) +def test_agreeing_signals_pass(reported: str | None, observed: str | None, why: str) -> None: + """Agreement is by selected adapter, not by string equality. + + A patch or minor difference between the two reads is not a disagreement worth + refusing a connection over: both values select the same parser, so no value in + the tree can be misread. Comparing the strings would turn a routine firmware + release into an outage. + """ + _client(reported=reported, observed=observed)._assert_transports_agree_on_schema_generation() + + +@pytest.mark.parametrize( + ("reported", "observed"), + [ + (None, "1.0"), # the simulator's actual failure: MQTT v1.0, REST silent + ("1.0", None), # the mirror image: REST claims v1.0, the tree is flat + ("1.0", "2.0"), # both present, different majors + ], +) +def test_disagreeing_signals_raise(reported: str | None, observed: str | None) -> None: + """Refusing follows the rule dispatch already applies to an unparseable version. + + An unknown schema generation means every value in the tree may be misread, so the + blast radius is the whole panel rather than one property. A warning would leave a + consumer running on wrong numbers; the error names both values so the offending + transport is obvious without a packet capture. + """ + client = _client(reported=reported, observed=observed) + + with pytest.raises(SpanPanelSchemaVersionError) as exc: + client._assert_transports_agree_on_schema_generation() + + message = str(exc.value) + assert repr(reported) in message + assert repr(observed) in message + + +def test_an_unparseable_mqtt_value_is_reported_as_such() -> None: + """A present-but-unreadable MQTT value is its own failure, not a silent pass. + + Dispatch already refused any unparseable *REST* value before the connection got + this far, so an unparseable value here can only have come from MQTT -- and saying + so is what stops the reader hunting through the REST response for it. + """ + client = _client(reported=None, observed="not-a-version") + + with pytest.raises(SpanPanelSchemaVersionError, match="no adapter major"): + client._assert_transports_agree_on_schema_generation() + + +def test_the_root_devices_property_is_the_one_observed() -> None: + """A child's copy must not be mistaken for the panel's. + + Under parent/child every device has its own `info` node, so the topic is matched + on the root serial rather than on the property name alone. Without that, a BESS + or MID publishing the property would overwrite the panel's answer -- and it would + do so non-deterministically, depending on retained-message ordering. + """ + client = _client(reported="1.0", observed=None) + + client._on_message(f"ebus/5/{SERIAL}-bess/info/data-model-version", "9.9") + assert client._observed_data_model_version is None, "a child's copy must be ignored" + + client._on_message(f"ebus/5/{SERIAL}/info/data-model-version", "1.0") + assert client._observed_data_model_version == "1.0" diff --git a/tests/test_schema_one_conformance.py b/tests/test_schema_one_conformance.py index 6e9d54d..be1f869 100644 --- a/tests/test_schema_one_conformance.py +++ b/tests/test_schema_one_conformance.py @@ -90,7 +90,7 @@ def _peer_fixtures() -> dict[str, str]: return {str(kind): str(path) for kind, path in fixtures.items()} -def _checkout(variable: str, what: str) -> Path: +def _checkout(variable: str, what: str, expect: str | None = None) -> Path: """A sibling checkout named by an environment variable, or skip. A variable that is unset and one pointing at a directory that is gone are the @@ -98,6 +98,13 @@ def _checkout(variable: str, what: str) -> Path: a stale path through instead produces a FileNotFoundError from somewhere deep in a comparison, which reads as a broken test rather than an unconfigured one. Set them in `.env`; see `.env.example`. + + "Gone" includes *emptied*, which is the form this actually takes. A checkout under + a temp directory keeps its `.git` and its directory tree while the reaper removes + the files, so `is_dir()` was true at every level and the comparison still raised. + Presence of a directory proves nothing here; the caller names one that must hold + at least one `.json`, which is what distinguishes a populated checkout from the + skeleton of a reaped one. """ configured = os.environ.get(variable) if not configured: @@ -105,6 +112,8 @@ def _checkout(variable: str, what: str) -> Path: path = Path(configured) if not path.is_dir(): pytest.skip(f"{variable}={configured} does not exist; point it at {what}") + if expect is not None and not any((path / expect).glob("*.json")): + pytest.skip(f"{variable}={configured} has no files under {expect}/ — the checkout is empty or is not {what}") return path @@ -444,7 +453,11 @@ def test_vendored_catalogs_are_byte_identical_to_the_specification() -> None: that must run everywhere, and making them depend on a second repository would mean they stop running. """ - spec = _checkout("EBUS_SPEC_DIR", "a specification checkout to verify vendored bytes") + spec = _checkout( + "EBUS_SPEC_DIR", + "a specification checkout to verify vendored bytes", + expect="capabilities", + ) differing = [ path.name for path in sorted(_CATALOGS.glob("*.json")) @@ -471,9 +484,9 @@ def test_the_vendored_captures_match_the_simulator() -> None: tree_source = sim_dir / fixtures["tree"] assert tree_source.exists(), f"{tree_source} is missing; is {sim_dir} on {ref}?" - assert tree_source.read_bytes() == _SIMULATOR_TREE.read_bytes(), ( - f"the captured tree differs from {tree_source}. Re-capture it and update peer.commit " f"(recorded: {commit})." - ) + assert ( + tree_source.read_bytes() == _SIMULATOR_TREE.read_bytes() + ), f"the captured tree differs from {tree_source}. Re-capture it and update peer.commit (recorded: {commit})." wire_source = sim_dir / fixtures["wire"] assert wire_source.exists(), f"{wire_source} is missing; is {sim_dir} on {ref}?" From a0083b0cbacd34e9b597e38daf39bbfc6d243bb9 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:00:41 -0700 Subject: [PATCH 068/115] Import the adapter the panel needs, off the event loop Two defects with one cause: discovery resolved the whole entry-point group up front, on the calling thread. A flat panel therefore imported schema_1 -- and with it the eBus SDK and jsonschema -- for a parser it would never call. That undoes the containment schema-1's own packaging sets up, where the SDK dependency is isolated to that distribution precisely so a flat install stays clear of it. True of installation, false at runtime. Redispatch then made installing both adapters the normal setup, so "installed" stopped implying "used" and eager resolution stopped being defensible. Home Assistant reported the whole sequence -- listdir, read_text, open, scandir -- as blocking calls inside the event loop and asked for a bug report, with config entry setup stalled 2.033s on a cold import cache. Enumeration and resolution are now separate steps. installed_adapter_keys() reads distribution metadata only; an adapter is imported the first time a panel asks for that key. The async paths run both through asyncio.to_thread. Resolution caches per key, which is what keeps _on_pre_rebuild -- a synchronous bridge callback with no thread to defer to -- free of I/O. discover_adapters() is replaced by installed_adapter_keys(), which returns registered names rather than a registry of loaded classes: verifying every name would mean importing every package, which is the cost being removed. SpanMqttClient.available_adapters becomes installed_adapters for the same reason. Neither had a consumer outside this package. Also closes an unguarded raise the change made prominent: the redispatch path resolves the new adapter before touching any state, so a flat-only install meeting a v1.0 panel logs which package is missing and keeps the parser it has, rather than throwing SpanPanelAdapterMissingError out of a fire-and-forget task as a bare traceback. Measured after: a flat resolve leaves ebus_sdk and jsonschema unimported. Both new tests fail on the unfixed code -- one reporting discovery on the event loop thread, the other counting two entry-point loads where one is correct. --- CHANGELOG.md | 7 ++ RELEASE.md | 8 +- scripts/verify_adapterless_install.py | 9 +- src/span_panel_api/adapters.py | 165 +++++++++++++++----------- src/span_panel_api/factory.py | 6 +- src/span_panel_api/mqtt/client.py | 75 ++++++++++-- tests/test_adapters_discovery.py | 76 +++++++++--- tests/test_factory_dispatch.py | 2 +- tests/test_mqtt_connect_flow.py | 53 +++++++++ 9 files changed, 302 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b432d9..1134bb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,13 @@ Pre-release. Normalises DER identity onto v1.0's vocabulary, and stops deriving ### Fixed +- **Adapter discovery no longer blocks the caller's event loop, and no longer imports adapters the panel will never use.** Two defects with one cause: discovery resolved the whole entry-point group up front, on the calling thread. A flat panel therefore + imported `schema_1` — and with it the eBus SDK and jsonschema — on every connection, for a parser it would not call. Home Assistant reported the whole sequence (`listdir`, `read_text`, `open`, `scandir`) as blocking calls inside the event loop and asked + for a bug report, with setup stalled 2.0s on a cold import cache. Enumeration and resolution are now separate: `installed_adapter_keys()` reads distribution metadata only, and an adapter is imported the first time a panel asks for that key. The async + paths run both in a thread. Resolution stays cached per key, which is what keeps the synchronous pre-rebuild callback free of I/O. **`discover_adapters()` is replaced by `installed_adapter_keys()`**, which returns registered names rather than a registry + of loaded classes — verifying every name would mean importing every package, which is the cost being removed. `SpanMqttClient.available_adapters` becomes `installed_adapters` for the same reason. +- **A firmware upgrade to a schema generation this install cannot parse is reported instead of raised into a background task.** The redispatch path resolves the new adapter before touching any state, so a flat-only install that meets a v1.0 panel logs + which package is missing and keeps the parser it has. Previously `SpanPanelAdapterMissingError` escaped a fire-and-forget task as a bare traceback. - **`dsm_state` and `current_run_config` are read from the MID instead of reading `UNKNOWN`.** Both are existing entities that had degraded on v1.0 — not because a source vanished, but because `schema_0` _derives_ them and the derivation was never ported. v1.0 states the answer, so the multi-signal heuristic is gone: sensed from a ready MID, falling back to the user's `shed/asserted-islanding-state` when it is not ready, then to a `power-flows/grid` heuristic when there is no MID at all, and unknown otherwise. A missing MID never reports on-grid — it means SPAN is not the islanding authority, not that the site is on grid, and a generator-fed island is the counterexample. `PANEL_BACKUP` versus `PANEL_OFF_GRID` becomes authoritative rather than diff --git a/RELEASE.md b/RELEASE.md index 0d11a33..0810939 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -162,9 +162,9 @@ CI going green proves the build, not the install. The seam this repository is bu # 1. The bootstrap alone must fail by name, not with ModuleNotFoundError python3 -m venv .solo && ./.solo/bin/pip install --pre span-panel-api ./.solo/bin/python -c " -from span_panel_api.adapters import discover_adapters, resolve_adapter, DEFAULT_ADAPTER_KEY +from span_panel_api.adapters import installed_adapter_keys, resolve_adapter, DEFAULT_ADAPTER_KEY from span_panel_api.exceptions import SpanPanelAdapterMissingError -print('adapters:', sorted(discover_adapters())) +print('adapters:', installed_adapter_keys()) try: resolve_adapter(DEFAULT_ADAPTER_KEY, 'release check') except SpanPanelAdapterMissingError as exc: @@ -174,8 +174,8 @@ except SpanPanelAdapterMissingError as exc: # 2. Both packages: the adapter resolves through discovery python3 -m venv .both && ./.both/bin/pip install --pre span-panel-api span-panel-api-schema-0 ./.both/bin/python -c " -from span_panel_api.adapters import discover_adapters -print('adapters:', sorted(discover_adapters())) +from span_panel_api.adapters import installed_adapter_keys +print('adapters:', installed_adapter_keys()) " ``` diff --git a/scripts/verify_adapterless_install.py b/scripts/verify_adapterless_install.py index 912f764..fbdbd98 100644 --- a/scripts/verify_adapterless_install.py +++ b/scripts/verify_adapterless_install.py @@ -37,13 +37,12 @@ def main() -> None: except ModuleNotFoundError as exc: _fail(f"bootstrap import reaches an adapter package: {exc}") - # 2. No adapter should be discoverable. If one is, the bootstrap wheel is + # 2. No adapter should be registered. If one is, the bootstrap wheel is # still carrying the entry point and the split did not actually happen. - from span_panel_api.adapters import DEFAULT_ADAPTER_KEY, discover_adapters + from span_panel_api.adapters import DEFAULT_ADAPTER_KEY, installed_adapter_keys - registry = discover_adapters() - if registry: - _fail(f"bootstrap-only install discovered adapters {sorted(registry)}; the entry point did not move") + if keys := installed_adapter_keys(): + _fail(f"bootstrap-only install registers adapters {keys}; the entry point did not move") # 3. Constructing a client must still work — only building a parser needs an # adapter. This is what keeps the failure at an actionable point. diff --git a/src/span_panel_api/adapters.py b/src/span_panel_api/adapters.py index 5cf0be1..44f2048 100644 --- a/src/span_panel_api/adapters.py +++ b/src/span_panel_api/adapters.py @@ -1,13 +1,32 @@ """Adapter discovery via the `span_panel_api.schema_adapters` entry-point group. -Called once per process on the first create_span_client(). A venv change needs a -process restart regardless, so a process-lifetime cache is correct. +Two steps, deliberately separate, because they cost very different things: + +*Enumeration* reads distribution metadata and answers "which adapter keys does +this environment register". *Resolution* imports one of those packages and +checks it implements the contract. Enumeration is a couple of file reads; +resolution of ``schema_1`` drags in the eBus SDK and jsonschema — measured at +two seconds on a cold import cache. + +So only the key the panel actually reports is ever imported. An earlier version +resolved the whole group up front to build one registry, which meant every flat +panel paid for the parent/child parser it would never call — undoing the +containment schema-1's own packaging sets up, where the SDK dependency is +isolated to that distribution precisely so a flat install stays clear of it. +Under redispatch both adapters are the normal install, so "installed" stopped +implying "used" and eager resolution stopped being defensible. + +Both steps cache for the life of the process. A venv change needs a restart +regardless, so nothing here can go stale while it matters. + +**Everything in this module does blocking file I/O**, both the metadata reads +and the imports. Callers on an event loop must keep it off theirs; the async +transport does that with ``asyncio.to_thread``. """ from __future__ import annotations -from dataclasses import dataclass -from importlib.metadata import entry_points +from importlib.metadata import EntryPoint, entry_points import logging from typing import TypeGuard @@ -17,23 +36,16 @@ _LOGGER = logging.getLogger(__name__) _ENTRY_POINT_GROUP = "span_panel_api.schema_adapters" - -@dataclass(frozen=True) -class _Discovery: - """One scan of the entry-point group: what was usable, and why the rest was not. - - Rejections are kept rather than only logged. A rejected adapter and an - absent one are the same absence from ``adapters``, but they are opposite - problems for whoever hits them — install something, versus upgrade what is - already installed. Keeping the reason is what lets ``resolve_adapter`` tell - them apart at the point the distinction matters, without re-scanning. - """ - - adapters: dict[str, type[SchemaAdapter]] - rejected: dict[str, str] - - -_DISCOVERY: _Discovery | None = None +# Every entry point in the group, by name, unloaded. None means "not scanned". +_ENTRY_POINTS: dict[str, EntryPoint] | None = None +# Resolution verdicts, filled one key at a time. A key appears in exactly one: +# usable adapters here, and the reason for the rest in _REJECTED. Kept apart +# rather than as one nullable map because a rejected adapter and an absent one +# are opposite problems for whoever hits them — upgrade what is already +# installed, versus install something — and resolve_adapter can only tell them +# apart if the reason survives the scan that produced it. +_ADAPTERS: dict[str, type[SchemaAdapter]] = {} +_REJECTED: dict[str, str] = {} def _derive_required_members(protocol: type) -> tuple[str, ...]: @@ -131,71 +143,90 @@ def _contract_defect(adapter_cls: type[SchemaAdapter]) -> str | None: return None -def _discover() -> _Discovery: - """Scan and cache the entry-point group, keeping rejections alongside adapters. +def _enumerate() -> dict[str, EntryPoint]: + """Scan the entry-point group by name, importing nothing. - A bad entry point is skipped with a logged reason, never raised: one broken - third-party adapter must not take down a panel whose own adapter is fine. - Whether a skip matters is decided later, by whoever asks for that key. + Names only, because a name is all it takes to answer the two questions asked + before a panel has reported anything: what is installed, and does the key + this panel needs appear at all. Loading is deferred to whoever asks for a + specific key. """ - global _DISCOVERY # pylint: disable=global-statement # process-lifetime cache by design - if _DISCOVERY is None: - adapters: dict[str, type[SchemaAdapter]] = {} - rejected: dict[str, str] = {} + global _ENTRY_POINTS # pylint: disable=global-statement # process-lifetime cache by design + if _ENTRY_POINTS is None: + found: dict[str, EntryPoint] = {} for ep in entry_points(group=_ENTRY_POINT_GROUP): - if ep.name in adapters or ep.name in rejected: + if ep.name in found: _LOGGER.warning("Duplicate schema adapter entry point %r; keeping the first found", ep.name) continue - try: - loaded: object = ep.load() - except Exception: # pylint: disable=broad-exception-caught - _LOGGER.exception("Failed to load schema adapter entry point %r", ep.name) - rejected[ep.name] = "the package raised on import; see the logged traceback." - continue - if not _is_adapter_class(loaded): - shape_defect = _describe_defect(loaded) - _LOGGER.error("Ignoring schema adapter entry point %r: %s", ep.name, shape_defect) - rejected[ep.name] = shape_defect - continue - if (contract_defect := _contract_defect(loaded)) is not None: - _LOGGER.error("Ignoring schema adapter entry point %r: %s", ep.name, contract_defect) - rejected[ep.name] = contract_defect - continue - adapters[ep.name] = loaded - _DISCOVERY = _Discovery(adapters=adapters, rejected=rejected) - return _DISCOVERY + found[ep.name] = ep + _ENTRY_POINTS = found + return _ENTRY_POINTS -def discover_adapters() -> dict[str, type[SchemaAdapter]]: - """Every adapter class this package can actually drive, by entry-point name. +def _load_and_check(ep: EntryPoint) -> type[SchemaAdapter] | str: + """Import one adapter and vet it, returning the class or the reason it is unusable. - Rejected entry points are deliberately absent rather than present-but-broken: - a caller iterating this should never have to re-check what discovery already - decided. + A defect is returned rather than raised so the caller decides what it means. + Discovery has no standing to fail a connection: whether an unusable adapter + matters depends entirely on whether the panel needs that key. """ - return _discover().adapters + try: + loaded: object = ep.load() + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.exception("Failed to load schema adapter entry point %r", ep.name) + return "the package raised on import; see the logged traceback." + if not _is_adapter_class(loaded): + shape_defect = _describe_defect(loaded) + _LOGGER.error("Ignoring schema adapter entry point %r: %s", ep.name, shape_defect) + return shape_defect + if (contract_defect := _contract_defect(loaded)) is not None: + _LOGGER.error("Ignoring schema adapter entry point %r: %s", ep.name, contract_defect) + return contract_defect + return loaded + + +def installed_adapter_keys() -> list[str]: + """Every adapter key this environment registers, sorted. + + Registered, not verified: naming a key here says a package claims it, not + that the package loads or implements the current contract. Verifying would + mean importing all of them, which is the cost this split exists to avoid, + and the distinction only ever matters for one key — the one the panel needs, + which ``resolve_adapter`` imports and vets on the spot. + """ + return sorted(_enumerate()) def resolve_adapter(key: str, reason: str) -> type[SchemaAdapter]: - """Return the discovered adapter class for `key`, or raise saying why not. + """Return the adapter class for `key`, importing it on first use, or raise saying why not. The one place an unavailable adapter turns into a named error. Both the factory's Tier 1 dispatch and the transport's default path go through here so a user whose panel outruns their install sees the same message either way. - Absent and rejected are separated here rather than at discovery, because - only here is it known that this particular key is the one the panel needs. + Absent and rejected stay distinct: nothing registers the key at all, versus + something does and cannot be driven. Same absence, opposite remedies. """ - discovery = _discover() - adapter_cls = discovery.adapters.get(key) - if adapter_cls is not None: - return adapter_cls - if (defect := discovery.rejected.get(key)) is not None: - raise SpanPanelAdapterIncompatibleError(needed=key, reason=reason, defect=defect) - raise SpanPanelAdapterMissingError(needed=key, reason=reason, available=sorted(discovery.adapters)) + if (cached := _ADAPTERS.get(key)) is not None: + return cached + if (cached_defect := _REJECTED.get(key)) is not None: + raise SpanPanelAdapterIncompatibleError(needed=key, reason=reason, defect=cached_defect) + + ep = _enumerate().get(key) + if ep is None: + raise SpanPanelAdapterMissingError(needed=key, reason=reason, available=installed_adapter_keys()) + + outcome = _load_and_check(ep) + if isinstance(outcome, str): + _REJECTED[key] = outcome + raise SpanPanelAdapterIncompatibleError(needed=key, reason=reason, defect=outcome) + _ADAPTERS[key] = outcome + return outcome def _reset_adapter_cache() -> None: """Test hook. Not public API.""" - global _DISCOVERY # pylint: disable=global-statement # test hook for the cache above - _DISCOVERY = None + global _ENTRY_POINTS # pylint: disable=global-statement # test hook for the cache above + _ENTRY_POINTS = None + _ADAPTERS.clear() + _REJECTED.clear() diff --git a/src/span_panel_api/factory.py b/src/span_panel_api/factory.py index 50cbb46..3e3e5d1 100644 --- a/src/span_panel_api/factory.py +++ b/src/span_panel_api/factory.py @@ -6,6 +6,7 @@ from __future__ import annotations +import asyncio import logging from .adapters import resolve_adapter @@ -81,7 +82,10 @@ async def create_span_client( # a wrong parser being discovered by its output. schema = await get_homie_schema(host, port=port) adapter_key, dispatch_reason = select_adapter_key(schema.data_model_version) - adapter_cls = resolve_adapter(adapter_key, dispatch_reason) + # In a thread: resolution reads distribution metadata and imports the adapter + # package, and this is the first call in the process to do either. See + # `adapters` — none of it is safe to run on an event loop. + adapter_cls = await asyncio.to_thread(resolve_adapter, adapter_key, dispatch_reason) client = SpanMqttClient( host, diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index e4fedae..0a9a3fb 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -16,10 +16,12 @@ from span_panel_api.schema_drift import log_schema_drift -from ..adapters import discover_adapters, resolve_adapter +from ..adapters import installed_adapter_keys, resolve_adapter from ..auth import get_homie_schema from ..dispatch import select_adapter_key from ..exceptions import ( + SpanPanelAdapterIncompatibleError, + SpanPanelAdapterMissingError, SpanPanelConnectionError, SpanPanelSchemaVersionError, SpanPanelServerError, @@ -109,11 +111,40 @@ def __init__( # fetch is retrying, and each would otherwise start its own retry loop. self._redispatch_in_flight = False + async def _preload_adapter(self, schema: V2HomieSchema) -> None: + """Resolve this schema's adapter in a thread, ahead of building it. + + Everything in ``adapters`` does blocking file I/O: entry-point + enumeration reads distribution metadata, and resolving ``schema_1`` + imports the eBus SDK and jsonschema. Done on the event loop that is a + two-second stall on a cold import cache, which Home Assistant reports as + a blocking call and asks for a bug report about. + + Resolution caches per key for the life of the process, so this leaves + ``_build_adapter``'s own resolve a dict lookup on every path that + follows — including ``_on_pre_rebuild``, which runs from a synchronous + bridge callback with no thread to defer to and depends on exactly that. + + Nothing to do when a factory was injected: that path never consults + discovery, which is what lets an adapter-less install run one. + + Raises: + SpanPanelSchemaVersionError: The version reads as no schema major. + SpanPanelAdapterMissingError: Nothing registers the key it selects. + SpanPanelAdapterIncompatibleError: Something does, and cannot be driven. + """ + if self._adapter_factory is not None: + return + adapter_key, dispatch_reason = select_adapter_key(schema.data_model_version) + await asyncio.to_thread(resolve_adapter, adapter_key, dispatch_reason) + def _build_adapter(self, schema: V2HomieSchema) -> SchemaAdapter: """Construct the parser for this session. Called from connect() and from the reconnect path — the only two - places a parser is built today. + places a parser is built today. Both await ``_preload_adapter`` first, + so the resolve below is a cache hit and this stays safe to call from a + synchronous context. With no injected factory this dispatches on the schema rather than assuming the flat adapter. That matters because a client can be built @@ -171,9 +202,14 @@ def schema_dispatch_reason(self) -> str: return self._schema_dispatch_reason @property - def available_adapters(self) -> list[str]: - """Return the sorted keys of every schema adapter discovered in this process.""" - return sorted(discover_adapters()) + def installed_adapters(self) -> list[str]: + """Return the sorted keys every installed package registers an adapter for. + + Registered, not vetted — see ``installed_adapter_keys``. Reads + distribution metadata off disk on first call, so an event loop should + reach it through a thread. + """ + return installed_adapter_keys() def _require_adapter(self) -> SchemaAdapter: """Return the SchemaAdapter, raising if not yet connected.""" @@ -230,15 +266,20 @@ async def connect(self) -> None: # schema yet, so it fetches here and dispatches in _build_adapter. schema = self._schema if self._schema is not None else await get_homie_schema(self._host, port=self._panel_http_port) self._schema = schema + await self._preload_adapter(schema) adapter = self._build_adapter(schema) + # Threaded on its own account: the preload above skips discovery entirely + # when a factory was injected, and this line would then be the first thing + # to read distribution metadata — on the loop. + installed = await asyncio.to_thread(installed_adapter_keys) _LOGGER.info( - "MQTT adapter selected: %s (span-panel-api %s)\n data-model-version: %r\n reason: %s\n available: %s", + "MQTT adapter selected: %s (span-panel-api %s)\n data-model-version: %r\n reason: %s\n installed: %s", adapter.schema_major, version("span-panel-api"), self._data_model_version, self._schema_dispatch_reason, - sorted(discover_adapters()), + installed, ) # Detect schema drift from previous connection @@ -728,6 +769,26 @@ async def _redispatch_if_generation_changed(self) -> None: if new_key == old_key: return + # Before anything is mutated, because this is where the upgrade can turn + # out to be one this install cannot follow: a flat panel that becomes + # v1.0 needs a package a flat-only install has no reason to have. The + # caller is a fire-and-forget task, so an escaping error would surface as + # a bare traceback; naming the missing package and keeping the parser we + # have is the same non-fatal stance the fetch retry takes above. + try: + await self._preload_adapter(schema) + except (SpanPanelAdapterMissingError, SpanPanelAdapterIncompatibleError) as exc: + _LOGGER.error( + "Panel upgraded from schema generation %s to %s, but this install cannot " + "parse the new one: %s. Keeping the %s parser, which will report missing " + "data rather than wrong data until the adapter is installed.", + old_key, + new_key, + exc, + old_key, + ) + return + _LOGGER.warning( "Panel changed schema generation while connected: data-model-version %r -> " "%r (%s -> %s). Rebuilding the parser; entities will repopulate from the " diff --git a/tests/test_adapters_discovery.py b/tests/test_adapters_discovery.py index 71e1a06..5d5a04f 100644 --- a/tests/test_adapters_discovery.py +++ b/tests/test_adapters_discovery.py @@ -7,9 +7,8 @@ from span_panel_api.adapters import ( DEFAULT_ADAPTER_KEY, - _Discovery, _reset_adapter_cache, - discover_adapters, + installed_adapter_keys, resolve_adapter, ) from span_panel_api.exceptions import SpanPanelAdapterIncompatibleError, SpanPanelAdapterMissingError @@ -22,15 +21,16 @@ def test_discovers_the_self_registered_schema_zero_adapter() -> None: _reset_adapter_cache() - registry = discover_adapters() - assert "schema_0" in registry - assert registry["schema_0"].__name__ == "SchemaZeroAdapter" + assert "schema_0" in installed_adapter_keys() + assert resolve_adapter("schema_0", "test").__name__ == "SchemaZeroAdapter" -def test_registry_is_cached_across_calls() -> None: +def test_resolution_is_cached_across_calls() -> None: + """Per key, and it has to be: `_on_pre_rebuild` resolves from a synchronous + bridge callback and relies on there being no import left to do.""" _reset_adapter_cache() - assert discover_adapters() is discover_adapters() + assert resolve_adapter("schema_0", "test") is resolve_adapter("schema_0", "test") # --------------------------------------------------------------------------- @@ -45,12 +45,12 @@ def _client(adapter_factory: object = None) -> SpanMqttClient: def _nothing_installed() -> Any: - """Patch discovery to a completed scan that found nothing. + """Patch enumeration to a completed scan that found nothing. A completed empty scan, not a missing one: `None` would make the next call re-scan and pick up this environment's real adapters. """ - return patch("span_panel_api.adapters._DISCOVERY", _Discovery(adapters={}, rejected={})) + return patch("span_panel_api.adapters._ENTRY_POINTS", {}) def test_default_factory_resolves_the_flat_adapter_through_discovery() -> None: @@ -61,7 +61,7 @@ def test_default_factory_resolves_the_flat_adapter_through_discovery() -> None: adapter = client._build_adapter(MOCK_SCHEMA) assert adapter.schema_major == DEFAULT_ADAPTER_KEY - assert type(adapter) is discover_adapters()[DEFAULT_ADAPTER_KEY] + assert type(adapter) is resolve_adapter(DEFAULT_ADAPTER_KEY, "test") def test_constructing_a_client_does_not_require_an_installed_adapter() -> None: @@ -86,12 +86,17 @@ def test_building_a_parser_without_any_adapter_raises_by_name() -> None: def test_an_explicit_factory_bypasses_discovery_entirely() -> None: - """Injection still wins — used by the factory's Tier 1 dispatch and by tests.""" + """Injection still wins — used by the factory's Tier 1 dispatch and by tests. + + Patched where the client looks it up rather than where it is defined: the + module imports the name, so patching `adapters.resolve_adapter` rebinds a + reference `_build_adapter` never reads, and the assertion could not fire. + """ _reset_adapter_cache() - real_cls = discover_adapters()[DEFAULT_ADAPTER_KEY] + real_cls = resolve_adapter(DEFAULT_ADAPTER_KEY, "test") client = _client(adapter_factory=real_cls) - with patch("span_panel_api.adapters.discover_adapters", side_effect=AssertionError("must not be consulted")): + with patch("span_panel_api.mqtt.client.resolve_adapter", side_effect=AssertionError("must not be consulted")): adapter = client._build_adapter(MOCK_SCHEMA) assert type(adapter) is real_cls @@ -116,15 +121,58 @@ class _FakeEntryPoint: def __init__(self, name: str, value: object) -> None: self.name = name self._value = value + self.loads = 0 def load(self) -> object: + self.loads += 1 return self._value def _discover_with(*eps: _FakeEntryPoint) -> dict[str, object]: + """Every entry point that survives vetting, resolved one key at a time. + + This is what the eager registry used to be, rebuilt by the test rather than + by the module — discovery no longer produces such a map, because producing + one is exactly the import-everything cost the split removed. The vetting + rules below are unchanged and still deserve asserting individually, so the + map is reconstructed here instead of rewriting each of them into a + try/except around a single resolve. + """ _reset_adapter_cache() + usable: dict[str, object] = {} with patch("span_panel_api.adapters.entry_points", return_value=list(eps)): - return dict(discover_adapters()) + for name in installed_adapter_keys(): + try: + usable[name] = resolve_adapter(name, "test") + except (SpanPanelAdapterMissingError, SpanPanelAdapterIncompatibleError): + continue + return usable + + +def test_resolving_one_key_leaves_the_others_unimported() -> None: + """The property the split exists for: a flat panel must not import schema_1. + + That package pulls in the eBus SDK and jsonschema — two seconds on a cold + import cache, and a dependency its own packaging confines to that + distribution precisely so a flat install stays clear of it. Eager discovery + imported it on every flat connection, and redispatch made installing both + adapters the normal setup, so "installed" stopped implying "used". + + Asserted on `load()` rather than on `sys.modules`, which by this point in a + test session has every adapter in it for unrelated reasons. + """ + from span_panel_api_schema_0 import SchemaZeroAdapter + + wanted = _FakeEntryPoint("schema_0", SchemaZeroAdapter) + other = _FakeEntryPoint("schema_9", SchemaZeroAdapter) + + _reset_adapter_cache() + with patch("span_panel_api.adapters.entry_points", return_value=[wanted, other]): + assert installed_adapter_keys() == ["schema_0", "schema_9"], "both must still be reported installed" + resolve_adapter("schema_0", "test") + + assert wanted.loads == 1 + assert other.loads == 0, "resolving one key must not import the rest" def _conforming_members(contract: object = ADAPTER_CONTRACT_VERSION) -> dict[str, object]: diff --git a/tests/test_factory_dispatch.py b/tests/test_factory_dispatch.py index 164ad96..4b51fb5 100644 --- a/tests/test_factory_dispatch.py +++ b/tests/test_factory_dispatch.py @@ -162,7 +162,7 @@ async def test_diagnostics_properties_before_and_after_connect(mqtt_client_mock: assert client.schema_major is None assert client.data_model_version is None assert client.schema_dispatch_reason == "not dispatched" - assert "schema_0" in client.available_adapters + assert "schema_0" in client.installed_adapters # Simulate what create_span_client does after adapter selection, ahead of connect(). client._data_model_version = None # pylint: disable=protected-access diff --git a/tests/test_mqtt_connect_flow.py b/tests/test_mqtt_connect_flow.py index 96b2805..2d52e17 100644 --- a/tests/test_mqtt_connect_flow.py +++ b/tests/test_mqtt_connect_flow.py @@ -320,6 +320,59 @@ async def test_connect_and_ready(self, mqtt_client_mock: MagicMock) -> None: assert await client.ping() is True mqtt_client_mock.subscribe.assert_called() + @pytest.mark.asyncio + async def test_adapter_discovery_never_touches_the_event_loop(self, mqtt_client_mock: MagicMock) -> None: + """Both halves of discovery do blocking file I/O, and connect() drives both. + + Enumeration reads distribution metadata; resolution imports the adapter + package, which for `schema_1` means the eBus SDK and jsonschema. Home + Assistant reported all of it — `listdir`, `read_text`, `open`, `scandir` + — as blocking calls in the event loop and asked for a bug report, with + the entry-point scan alone stalling setup for two seconds on a cold + import cache. + + Asserted on the two operations rather than on `resolve_adapter` being + called off-thread, because it is deliberately called twice: once in a + thread to warm the cache, then again by `_build_adapter` on the loop, + where a cache hit costs nothing. Watching the call would fail a correct + implementation; watching the I/O is the actual property. + """ + import threading + + from span_panel_api.adapters import _reset_adapter_cache + from span_panel_api_schema_0 import SchemaZeroAdapter + + loop_thread = threading.get_ident() + ran_on: dict[str, int] = {} + + class _RecordingEntryPoint: + name = "schema_0" + + def load(self) -> object: + ran_on["load"] = threading.get_ident() + return SchemaZeroAdapter + + def _enumerate(group: str) -> list[_RecordingEntryPoint]: + ran_on["enumerate"] = threading.get_ident() + return [_RecordingEntryPoint()] + + client = _make_span_client() + _reset_adapter_cache() + try: + with patch("span_panel_api.adapters.entry_points", side_effect=_enumerate): + connect_task = asyncio.create_task(client.connect()) + await asyncio.sleep(0.05) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") + await asyncio.wait_for(connect_task, timeout=5.0) + finally: + # The fake registry is process-wide; leaving it cached would hand + # every later test a single-entry-point environment. + _reset_adapter_cache() + + assert set(ran_on) == {"enumerate", "load"}, f"discovery did not run at all: {ran_on}" + assert loop_thread not in ran_on.values(), f"discovery ran on the event loop: {ran_on}" + @pytest.mark.asyncio async def test_close(self, mqtt_client_mock: MagicMock) -> None: client = _make_span_client() From 45ff3f998543fa0c29d1e1656b3bc016255c80c8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:08:24 -0700 Subject: [PATCH 069/115] Read this package's own version off the loop too Moving adapter discovery into a thread left `version("span-panel-api")` behind, on the loop, in the same log statement as the call that moved. Home Assistant went on reporting three blocking calls -- listdir, read_text and open, all against this package's dist-info METADATA -- for a defect that read as fixed. The two calls look unrelated and are the same kind of file I/O. Grouped into one helper so there is a single thing to run in a thread rather than two that can drift apart again. The test that should have caught it named only the operations already known about, so it agreed the code was correct. It now watches `version` as well, and fails on the exact state observed at runtime: discovery threaded, `version` on the loop. --- src/span_panel_api/mqtt/client.py | 26 +++++++++++++---- tests/test_mqtt_connect_flow.py | 48 +++++++++++++++++++------------ 2 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 0a9a3fb..877dd35 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -49,6 +49,17 @@ _REDISPATCH_RETRY_MAX_S = 8.0 +def _metadata_for_the_log() -> tuple[list[str], str]: + """Every distribution-metadata read connect() needs, in one place. + + Grouped so there is a single thing to run in a thread rather than two calls + that look unrelated and drift apart — which is exactly what happened once + already, when the adapter keys were moved off the event loop and the version + lookup beside them was not. + """ + return installed_adapter_keys(), version("span-panel-api") + + class SpanMqttClient: """MQTT transport — implements all span-panel-api protocols.""" @@ -269,14 +280,19 @@ async def connect(self) -> None: await self._preload_adapter(schema) adapter = self._build_adapter(schema) - # Threaded on its own account: the preload above skips discovery entirely - # when a factory was injected, and this line would then be the first thing - # to read distribution metadata — on the loop. - installed = await asyncio.to_thread(installed_adapter_keys) + # Both halves of this line read distribution metadata off disk, and both + # have to be gathered before it is logged. `version()` is the less obvious + # one — it opens this package's own dist-info METADATA — and it was left + # on the loop when its sibling was moved off, which Home Assistant went on + # reporting as three blocking calls after the rest was fixed. + # + # Threaded on their own account rather than relying on the preload above, + # which skips discovery entirely when a factory was injected. + installed, library_version = await asyncio.to_thread(_metadata_for_the_log) _LOGGER.info( "MQTT adapter selected: %s (span-panel-api %s)\n data-model-version: %r\n reason: %s\n installed: %s", adapter.schema_major, - version("span-panel-api"), + library_version, self._data_model_version, self._schema_dispatch_reason, installed, diff --git a/tests/test_mqtt_connect_flow.py b/tests/test_mqtt_connect_flow.py index 2d52e17..1f3b0e5 100644 --- a/tests/test_mqtt_connect_flow.py +++ b/tests/test_mqtt_connect_flow.py @@ -321,21 +321,26 @@ async def test_connect_and_ready(self, mqtt_client_mock: MagicMock) -> None: mqtt_client_mock.subscribe.assert_called() @pytest.mark.asyncio - async def test_adapter_discovery_never_touches_the_event_loop(self, mqtt_client_mock: MagicMock) -> None: - """Both halves of discovery do blocking file I/O, and connect() drives both. - - Enumeration reads distribution metadata; resolution imports the adapter - package, which for `schema_1` means the eBus SDK and jsonschema. Home - Assistant reported all of it — `listdir`, `read_text`, `open`, `scandir` - — as blocking calls in the event loop and asked for a bug report, with - the entry-point scan alone stalling setup for two seconds on a cold - import cache. - - Asserted on the two operations rather than on `resolve_adapter` being - called off-thread, because it is deliberately called twice: once in a - thread to warm the cache, then again by `_build_adapter` on the loop, - where a cache hit costs nothing. Watching the call would fail a correct - implementation; watching the I/O is the actual property. + async def test_no_package_metadata_is_read_on_the_event_loop(self, mqtt_client_mock: MagicMock) -> None: + """connect() reads packaging metadata three ways, and all of it is file I/O. + + Entry-point enumeration and `version()` both open dist-info off disk; + resolution imports the adapter package, which for `schema_1` means the + eBus SDK and jsonschema. Home Assistant reported the lot — `listdir`, + `read_text`, `open`, `scandir` — as blocking calls in the event loop and + asked for a bug report, with the entry-point scan alone stalling setup + for two seconds on a cold import cache. + + `version()` is watched because it was missed. Moving discovery off the + loop left it behind in the same log statement, and Home Assistant kept + reporting three blocking calls for a defect that read as fixed. A test + naming only the operations already known about would have agreed. + + Asserted on the operations rather than on `resolve_adapter` running + off-thread, because it is deliberately called twice: once in a thread to + warm the cache, then again by `_build_adapter` on the loop, where a cache + hit costs nothing. Watching the call would fail a correct implementation; + watching the I/O is the actual property. """ import threading @@ -356,10 +361,17 @@ def _enumerate(group: str) -> list[_RecordingEntryPoint]: ran_on["enumerate"] = threading.get_ident() return [_RecordingEntryPoint()] + def _version(name: str) -> str: + ran_on["version"] = threading.get_ident() + return "0.0.0-test" + client = _make_span_client() _reset_adapter_cache() try: - with patch("span_panel_api.adapters.entry_points", side_effect=_enumerate): + with ( + patch("span_panel_api.adapters.entry_points", side_effect=_enumerate), + patch("span_panel_api.mqtt.client.version", side_effect=_version), + ): connect_task = asyncio.create_task(client.connect()) await asyncio.sleep(0.05) client._on_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) @@ -370,8 +382,8 @@ def _enumerate(group: str) -> list[_RecordingEntryPoint]: # every later test a single-entry-point environment. _reset_adapter_cache() - assert set(ran_on) == {"enumerate", "load"}, f"discovery did not run at all: {ran_on}" - assert loop_thread not in ran_on.values(), f"discovery ran on the event loop: {ran_on}" + assert set(ran_on) == {"enumerate", "load", "version"}, f"not all of it ran: {ran_on}" + assert loop_thread not in ran_on.values(), f"metadata read on the event loop: {ran_on}" @pytest.mark.asyncio async def test_close(self, mqtt_client_mock: MagicMock) -> None: From 0e77b86caf001ca9b007b5041b9e3cf669030429 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:33:49 -0700 Subject: [PATCH 070/115] Raise schema-1's bootstrap floor, and give both adapters a b3 entry Found while preparing the first release of these three together, which is the only point at which the floors are load-bearing. `span-panel-api-schema-1` declares `span-panel-api>=3.0.0b2` and imports `SpanMidSnapshot`, which 3.0.0b2 does not define. Published as-is, a resolver could legally pair the wheel with 3.0.0b2 and the parser would fail on import -- the hazard RELEASE.md names under "Releasing every distribution". The floor moves to 3.0.0b3. `schema-0` keeps its b2 floor. Checked rather than assumed: every name it imports from the bootstrap, including `HOMIE_STATE_*` from `mqtt.const`, is present in the released v3.0.0b2 tree. Both adapters also had real changes since their b2 tags and nowhere to record them. schema-0 had no `[1.0.0b3]` section at all despite its manifest already declaring that version -- its two commits include the breaking DER identity normalisation, where `battery.model` changes value for existing flat users, so that is now written down where someone upgrading will find it. schema-1's work sat under `[Unreleased]`, retitled to `[0.1.0b3]`. --- packages/schema-0/CHANGELOG.md | 16 ++++++++++++++++ packages/schema-1/CHANGELOG.md | 6 +++++- packages/schema-1/pyproject.toml | 7 ++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/schema-0/CHANGELOG.md b/packages/schema-0/CHANGELOG.md index 3586a85..2ddf70f 100644 --- a/packages/schema-0/CHANGELOG.md +++ b/packages/schema-0/CHANGELOG.md @@ -7,6 +7,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is fixed — the flat single-device schema, SPAN firmware `r202603` through `r202627` — and is identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version number. A release here means this parser changed, never that the panel did. +## [1.0.0b3] - 08/2026 + +Pre-release. Requires `span-panel-api` 3.0.0b2 or newer — unchanged, because nothing added here reaches for anything newer. + +### Changed + +- **BREAKING — DER identity is translated into v1.0's vocabulary rather than mirroring flat's names.** `model` is the human designation and `part_number` the SKU, on `battery`, `evse` and `pv` alike; `product_name` is retired on all three. Flat is the + irregular side: it puts the SKU in `bess/model` and in `evse/part-number` — the same concept under two names — and gives PV neither. `schema_1` used to cross over to preserve each entity's displayed meaning, which worked and permanently encoded flat's + irregularity in the snapshot. This adapter now normalises instead: `bess/model` → `part_number`, `bess/product-name` → `model`. **`battery.model` changes value for existing flat users at this upgrade.** Measured: every EVSE identity field now reads + identically on both adapters, so for that device class identity stops being a migration delta at all. + +### Added + +- **`dominant_power_source_payload`.** Flat already speaks this vocabulary, so the value passes through — the method exists because `schema_1` must translate, and a caller should not have to know which schema is underneath. Validated rather than passed + blindly: an unrecognised value returns `None` and the transport refuses the command, matching `schema_1` rather than putting a string outside the enum on the wire. + ## [1.0.0b2] - 08/2026 Pre-release. Follows the reshaped `SchemaAdapter` protocol released in `span-panel-api` 3.0.0b2. diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index ff5848d..6234205 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -7,7 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is the parent/child device tree SPAN firmware `r202633+` publishes, identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version number. A release here means this parser changed, never that the panel did. -## [Unreleased] +## [0.1.0b3] - 08/2026 + +Pre-release. **Requires `span-panel-api` 3.0.0b3 or newer** — see Fixed. ### Added @@ -45,6 +47,8 @@ shape rather than bytes for the same reason its values are not asserted. Provena - **The conformance check was reading the wrong set of names.** Built from `_PROPERTY_FIELD_MAP` alone, it covered only properties that carry field metadata and silently skipped everything the snapshot mapper reads directly — the MID, `connection` feeds/fed-by, `info/direction`. `grid_state`, the most recently corrected mapping in this package, was among them. The read set is now derived from the source itself, so it cannot fall behind the code; that immediately surfaced `info/direction` as a fifteenth undeclared extension. +- **The bootstrap floor is raised to 3.0.0b3**, which is where it should always have been: this parser imports `SpanMidSnapshot`, and 3.0.0b2 does not define it. The declared `>=3.0.0b2` let a resolver pair this wheel with 3.0.0b2 and fail on import. + Caught before the first release that would have shipped it. `schema-0` keeps its b2 floor; every name it imports is present there, checked rather than assumed. ## [0.1.0b2] - 08/2026 diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index e88edab..2cb985c 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -9,7 +9,12 @@ readme = "README.md" license = "MIT" requires-python = ">=3.10,<4.0" dependencies = [ - "span-panel-api>=3.0.0b2,<4.0", + # b3, not b2: this parser imports `SpanMidSnapshot`, which 3.0.0b2 does not + # define. A `>=3.0.0b2` floor lets a resolver pair this wheel with 3.0.0b2 and + # fail on import -- the precise hazard RELEASE.md warns about under "Releasing + # every distribution". schema-0 keeps its b2 floor; every name it imports is + # present there, checked rather than assumed. + "span-panel-api>=3.0.0b3,<4.0", # Only this distribution depends on the eBus SDK. The bootstrap and # schema-0 stay clean, so a flat-panel install never pulls it in — which is # what bounds the release coupling this dependency introduces to panels on From 7e0d01079a0c559067bf5e518ad918fa8c3a947e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:40:01 -0700 Subject: [PATCH 071/115] Require twine 7, which knows what Metadata-Version 2.5 is CI's `build-check` failed on develop with `InvalidDistribution: '2.5' is not a valid metadata version`. The wheel is fine; the checker was stale. Nothing in this repository changed to cause it. `[build-system] requires = ["hatchling"]` is unpinned and resolved fresh in an isolated build environment, so it does not come from `uv.lock` and a new hatchling can change the emitted metadata version at any time. It did, to 2.5, and the locked twine 6.2.0 predates support for it. The next CI run was going to fail whatever triggered it -- ours happened to be two changelogs and a dependency floor. Verified by running the exact CI command against a locally built wheel and sdist: both PASSED on 7.0.0 and the wheel fails on 6.2.0. --- pyproject.toml | 7 ++++++- uv.lock | 14 +++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f61dae6..0a41437 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,12 @@ dev = [ "mypy", "pylint", "radon", - "twine", + # 7.0 or newer: hatchling emits `Metadata-Version: 2.5` and twine 6.2 rejects + # it as invalid. The build backend is resolved fresh at build time from an + # unpinned `[build-system] requires`, so the metadata version moves without + # anything in this repository changing -- which is how a green CI turned red + # on a commit that touched two changelogs and a dependency floor. + "twine>=7.0", "vulture>=2.14", "types-pyyaml>=6.0.12.20250915", "coverage", diff --git a/uv.lock b/uv.lock index 0c8113b..1166fa7 100644 --- a/uv.lock +++ b/uv.lock @@ -931,11 +931,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.0" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] @@ -1373,7 +1373,7 @@ dev = [ { name = "ruff", specifier = ">=0.15.5" }, { name = "span-panel-api-schema-0", editable = "packages/schema-0" }, { name = "span-panel-api-schema-1", editable = "packages/schema-1" }, - { name = "twine" }, + { name = "twine", specifier = ">=7.0" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, { name = "vulture", specifier = ">=2.14" }, ] @@ -1478,7 +1478,7 @@ wheels = [ [[package]] name = "twine" -version = "6.2.0" +version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "id" }, @@ -1491,9 +1491,9 @@ dependencies = [ { name = "rich" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/a8/949edebe3a82774c1ec34f637f5dd82d1cf22c25e963b7d63771083bbee5/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262, upload-time = "2025-09-04T15:43:17.255Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz", hash = "sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177", size = 215032, upload-time = "2026-07-27T15:59:00.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" }, + { url = "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl", hash = "sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7", size = 43204, upload-time = "2026-07-27T15:58:59.26Z" }, ] [[package]] From fd087ea45953bb880b12eb606c8204ce2ed0c50e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:53:29 -0700 Subject: [PATCH 072/115] feat(schema-1): carry MID and PV firmware, and the MID's hardware revision r202633 documents info/firmware-version on both the MID and the PV, and info/hardware-version on the MID. Nothing read them. SpanMidSnapshot and SpanPVSnapshot were the only snapshots with no firmware field, so mid_device_info could set manufacturer, model and serial_number but not sw_version -- where bess_device_info does, from the identical property, mapped from the start. A user saw a Microgrid Interconnect card with a model and a serial and no firmware, beside a battery showing all three. Found by valuing the properties in panelbench, which had never published them either. Both halves were silent, so neither end was visibly wrong: the simulator declared them and left them unvalued, and the library never asked. That is the failure mode panelbench's tests/fidelity/test_declared_but_unvalued.py exists to name -- an entity that never receives a state reaches a user as an unknown that never resolves, not as something they notice missing. software_version rather than firmware_version, matching SpanBatterySnapshot and SpanEvseSnapshot: the sub-devices share a spelling because a consumer builds all of them the same way, into DeviceInfo(sw_version=...). Only the enclosure calls it firmware_version, where it is the panel's own and predates the sub-device types. hardware_version is new to the library; the MID is the first device to carry one. Three tests, two of them mutation-verified: dropping either mapping fails them. The third pins that an unpublished revision stays None rather than becoming '', because DeviceInfo renders an empty string as a present-but-blank row and omits a None one. --- .../src/span_panel_api_schema_1/devices.py | 4 ++ src/span_panel_api/models.py | 16 ++++++ tests/test_schema_one_devices.py | 53 +++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/packages/schema-1/src/span_panel_api_schema_1/devices.py b/packages/schema-1/src/span_panel_api_schema_1/devices.py index 4ecfa56..5cd1c5b 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/devices.py +++ b/packages/schema-1/src/span_panel_api_schema_1/devices.py @@ -41,6 +41,7 @@ from ebus_sdk.homie import DiscoveredDevice PROP_FIRMWARE_VERSION = "firmware-version" +PROP_HARDWARE_VERSION = "hardware-version" PROP_MODEL = "model" PROP_NAMEPLATE_CAPACITY = "nameplate-capacity" PROP_NOMINAL_POWER = "nominal-power" @@ -135,6 +136,7 @@ def build_pv( return SpanPVSnapshot( vendor_name=_optional(text(pv, NODE_INFO, PROP_VENDOR_NAME)), model=_optional(text(pv, NODE_INFO, PROP_MODEL)), + software_version=_optional(text(pv, NODE_INFO, PROP_FIRMWARE_VERSION)), nameplate_capacity_w=number(pv, NODE_INFO, PROP_NOMINAL_POWER), feed_circuit_id=feeds.get(pv.device_id), # Retired as a property in v1.0 and derived instead, per the enclosure model's @@ -192,6 +194,8 @@ def build_mid(mid: DiscoveredDevice | None, device_names: Mapping[str, str]) -> serial_number=serial, vendor_name=_optional(text(mid, NODE_INFO, PROP_VENDOR_NAME)), model=_optional(text(mid, NODE_INFO, PROP_MODEL)), + software_version=_optional(text(mid, NODE_INFO, PROP_FIRMWARE_VERSION)), + hardware_version=_optional(text(mid, NODE_INFO, PROP_HARDWARE_VERSION)), islanding_state=_optional(text(mid, NODE_GRID, PROP_ISLANDING_STATE)), grid_state=_optional(text(mid, NODE_GRID, PROP_GRID_STATE)), grid_forming_entity=_optional(text(mid, NODE_GRID, PROP_GRID_FORMING_ENTITY)), diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 6cea4ba..7b92903 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -55,6 +55,13 @@ class SpanPVSnapshot: nameplate_capacity_w: float | None = None # pv/nameplate-capacity (W) feed_circuit_id: str | None = None # pv/feed (normalized circuit ID) relative_position: str | None = None # pv/relative-position (IN_PANEL | UPSTREAM | DOWNSTREAM) + software_version: str | None = None + """`info/firmware-version`, named as on `SpanBatterySnapshot` and `SpanEvseSnapshot`. + + Sub-devices share a spelling because a consumer builds all of them the same way — + into `DeviceInfo(sw_version=...)`. Only the enclosure calls it `firmware_version`, + where it is the panel's own and predates the sub-device types. + """ @dataclass(frozen=True, slots=True) @@ -100,6 +107,15 @@ class SpanMidSnapshot: """`grid/islanding-state` — ON_GRID / OFF_GRID. MUST on a MID, per the enclosure model.""" grid_state: str | None = None """`grid/grid-state` — whether utility power is present, distinct from islanding.""" + software_version: str | None = None + """`info/firmware-version`, spelled as on the other sub-devices — see `SpanPVSnapshot`.""" + hardware_version: str | None = None + """`info/hardware-version`. The MID is the first device to carry one into a snapshot. + + r202633 documents it on the MID's `info` node, and a consumer has a field for it + (`DeviceInfo(hw_version=...)`). Without it the MID's device card shows a model and a + serial and nothing else, beside a battery showing all three. + """ grid_forming_entity: str | None = None """`grid/grid-forming-entity` — the raw wire value: `GRID`, or a Homie device id.""" grid_forming_device_name: str | None = None diff --git a/tests/test_schema_one_devices.py b/tests/test_schema_one_devices.py index 4801713..e415dc0 100644 --- a/tests/test_schema_one_devices.py +++ b/tests/test_schema_one_devices.py @@ -212,3 +212,56 @@ def test_a_panel_with_no_mid_reports_none_rather_than_an_empty_device() -> None: A new optional device should not inherit that guessing game. """ assert build_mid(None, {}) is None + + +def test_the_mid_carries_its_own_firmware_and_hardware_revision() -> None: + """`info/firmware-version` and `info/hardware-version` reach the snapshot. + + r202633 documents both on the MID's `info` node, and a consumer has fields for + them (`DeviceInfo(sw_version=..., hw_version=...)`). Until these were mapped the + MID's device card showed a model and a serial and nothing else, beside a battery + showing all three — the battery's identical property having been mapped from the + start. Found by valuing them in the simulator, which had never published them + either, so nothing downstream had ever been asked for them. + + `software_version` rather than `firmware_version`: the sub-devices share a + spelling because a consumer builds all of them the same way. Only the enclosure + calls it `firmware_version`. + """ + device = _device("bess-mid") + device.update_property("info", "firmware-version", "sim-mid/v0.1.0") + device.update_property("info", "hardware-version", "rev1") + + mid = build_mid(device, {}) + + assert mid is not None + assert mid.software_version == "sim-mid/v0.1.0" + assert mid.hardware_version == "rev1" + + +def test_the_pv_carries_its_firmware_version() -> None: + """The other half of the same gap: `info/firmware-version` on the PV. + + Documented by r202633, published by the simulator, and dropped on the floor until + now. Unlike the MID there is no `hardware-version` to carry — the topic reference + documents five properties on the PV and that is not one of them. + """ + device = _device("pv") + device.update_property("info", "firmware-version", "sim-pv/v0.1.0") + + assert build_pv(device, {}).software_version == "sim-pv/v0.1.0" + + +def test_a_device_publishing_no_revision_reports_none_rather_than_empty_string() -> None: + """Absent stays absent, so a consumer can tell "not published" from "published blank". + + `DeviceInfo` renders an empty string as a present-but-blank row; `None` omits the + row. The reference tree publishes neither property, which is what makes it the + right fixture for this. + """ + mid = build_mid(_device("bess-mid"), {}) + + assert mid is not None + assert mid.software_version is None + assert mid.hardware_version is None + assert build_pv(_device("pv"), {}).software_version is None From b0000e2f2bde91c87810eeb584a9880b6f72b861 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:01:23 -0700 Subject: [PATCH 073/115] fix(mqtt): compute field_metadata at access, not during connect The parent/child adapter reads each device's $description, which has not arrived when connect() runs its setup, so the cached value was permanently empty on every schema_1 panel. --- src/span_panel_api/mqtt/client.py | 32 +++++++++++++++---------- tests/test_mqtt_client_connection.py | 36 ++++++++++++++++++++++++++++ tests/test_mqtt_connect_flow.py | 13 ++++++++-- 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 877dd35..df10b12 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -93,7 +93,6 @@ def __init__( self._loop: asyncio.AbstractEventLoop | None = None self._background_tasks: set[asyncio.Task[None]] = set() self._snapshot_timer: asyncio.TimerHandle | None = None - self._field_metadata: dict[str, FieldMetadata] | None = None self._schema_hash: str | None = None self._previous_schema_types: HomieSchemaTypes | None = None # Supplied by create_span_client, which already fetched it to dispatch @@ -247,12 +246,25 @@ def serial_number(self) -> str: @property def field_metadata(self) -> dict[str, FieldMetadata] | None: - """Schema-derived metadata for snapshot fields, or None before connect(). + """Schema-derived metadata for snapshot fields, or None before ready. Keyed by snapshot field path (e.g. ``"panel.instant_grid_power_w"``). - Built once during ``connect()`` from the Homie schema. + + Computed from the adapter's current view at access time rather than + cached during connect(). Under the parent/child schema the adapter reads + each device's `$description`, which has not arrived when connect() runs + its setup — a value captured there is permanently empty. Returning None + until the adapter is ready keeps the documented none-before-connect + sentinel and keeps "not ready" distinguishable from "ready with nothing". + + Cost: the schema_1 walk is devices x nodes x properties — under a + thousand dict operations for a 40-circuit panel — against an access rate + of once per connect-session. """ - return self._field_metadata + adapter = self._adapter + if adapter is None or not adapter.is_ready(): + return None + return adapter.build_field_metadata() async def connect(self) -> None: """Connect to MQTT broker and wait for Homie device ready. @@ -311,11 +323,6 @@ async def connect(self) -> None: self._schema_hash = new_hash self._previous_schema_types = schema.types - # Build transport-agnostic field metadata. The adapter holds the schema - # it was constructed with, so the transport no longer has to pick out - # the block a particular wire format keeps its type definitions in. - self._field_metadata = self._require_adapter().build_field_metadata() - _LOGGER.debug( "MQTT: Creating bridge to %s:%s (serial=%s)", self._broker_config.broker_host, @@ -821,7 +828,6 @@ async def _redispatch_if_generation_changed(self) -> None: # after having been rebuilt for a different one. self._data_model_version = schema.data_model_version adapter = self._build_adapter(schema) - self._field_metadata = adapter.build_field_metadata() # Ready is a property of the tree, and this is a different tree. Leaving the # old event set would let `is_ready()` answer for a parser that has not seen # a single message yet. @@ -848,12 +854,14 @@ def _on_pre_rebuild(self) -> None: any stale `$state=disconnected` cached during the outage so the new subscription's retained messages repopulate from a clean slate. - Schema-derived state (`_field_metadata`, `_schema_hash`, + Schema-derived state (`_schema`, `_schema_hash`, `_previous_schema_types`) is intentionally preserved — the Homie schema cannot change within a session, so the cache remains valid and a refetch would just add cost. If the panel reboots and the schema actually changed, the existing drift-detection log fires on - the next session's `connect()`. + the next session's `connect()`. `field_metadata` needs no preserving: + it reads the live adapter, so it re-derives itself from the rebuilt + tree once that tree is ready again. A cached schema is also what makes the rebuild safe to run from a synchronous callback. ``_build_adapter`` can raise — on an unreadable diff --git a/tests/test_mqtt_client_connection.py b/tests/test_mqtt_client_connection.py index cfef7c8..f1a0176 100644 --- a/tests/test_mqtt_client_connection.py +++ b/tests/test_mqtt_client_connection.py @@ -501,3 +501,39 @@ def factory(serial_number: str, schema: V2HomieSchema) -> SchemaZeroAdapter: assert seen == [("sim-40t-001", schema)] assert seen[0][1].panel_size == 40 assert isinstance(client.adapter, SchemaZeroAdapter) + + +def test_field_metadata_is_live_after_ready() -> None: + """field_metadata must reflect devices discovered AFTER connect() ran. + + Regression for the pre-discovery cache: the adapter is constructed with an + empty tree, so anything captured during connect() is permanently {}. + """ + from span_panel_api.models import FieldMetadata + + class FakeAdapter: + schema_major = "1" + ADAPTER_CONTRACT = 1 + SUPPORTS_DATA_MODEL_VERSIONS = ("1.0", "1.99") + + def __init__(self) -> None: + self.discovered = False + + def is_ready(self) -> bool: + return self.discovered + + def build_field_metadata(self) -> dict[str, FieldMetadata]: + if not self.discovered: + return {} + return {"circuit.instant_power_w": FieldMetadata(unit="W", datatype="float")} + + client = SpanMqttClient.__new__(SpanMqttClient) + adapter = FakeAdapter() + client._adapter = adapter + + # Before discovery: no metadata, and specifically not an empty dict, so + # callers can distinguish "not ready" from "ready with nothing". + assert client.field_metadata is None + + adapter.discovered = True + assert client.field_metadata == {"circuit.instant_power_w": FieldMetadata(unit="W", datatype="float")} diff --git a/tests/test_mqtt_connect_flow.py b/tests/test_mqtt_connect_flow.py index 1f3b0e5..d4e83cc 100644 --- a/tests/test_mqtt_connect_flow.py +++ b/tests/test_mqtt_connect_flow.py @@ -828,16 +828,25 @@ async def test_pre_rebuild_preserves_schema_state(self, mqtt_client_mock: MagicM schema_hash_before = client._schema_hash schema_types_before = client._previous_schema_types - field_metadata_before = client._field_metadata schema_before = client._schema + field_metadata_before = client.field_metadata + assert field_metadata_before is not None client._on_pre_rebuild() assert client._schema_hash == schema_hash_before assert client._previous_schema_types == schema_types_before - assert client._field_metadata == field_metadata_before assert client._schema == schema_before + # `field_metadata` reads the live adapter rather than a cache, so the + # fresh accumulator legitimately reads None until the new subscription's + # retained messages repopulate the tree. What survives the rebuild is the + # schema-derived *input*, observable as the same mapping once ready again. + assert client.field_metadata is None + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") + assert client.field_metadata == field_metadata_before + await client.close() @pytest.mark.asyncio From 8215cf11fa46d6082e03d678280bf4a2d5f248df Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:07:56 -0700 Subject: [PATCH 074/115] chore: bump to 3.0.0b4 and raise adapter floors FieldMetadata gains a new field in this branch and both adapters will emit it, so an adapter built here must not resolve against an older core. --- packages/schema-0/pyproject.toml | 4 ++-- packages/schema-1/pyproject.toml | 4 ++-- pyproject.toml | 2 +- uv.lock | 10 +++++----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/schema-0/pyproject.toml b/packages/schema-0/pyproject.toml index 376b32f..7496270 100644 --- a/packages/schema-0/pyproject.toml +++ b/packages/schema-0/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api-schema-0" -version = "1.0.0b3" +version = "1.0.0b4" description = "Flat-schema (data-model-version absent) parser for span-panel-api" authors = [ {name = "SpanPanel"} @@ -9,7 +9,7 @@ readme = "README.md" license = "MIT" requires-python = ">=3.10,<4.0" dependencies = [ - "span-panel-api>=3.0.0b2,<4.0", + "span-panel-api>=3.0.0b4,<4.0", ] [project.urls] diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index 2cb985c..4377755 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api-schema-1" -version = "0.1.0b3" +version = "0.1.0b4" description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api" authors = [ {name = "SpanPanel"} @@ -14,7 +14,7 @@ dependencies = [ # fail on import -- the precise hazard RELEASE.md warns about under "Releasing # every distribution". schema-0 keeps its b2 floor; every name it imports is # present there, checked rather than assumed. - "span-panel-api>=3.0.0b3,<4.0", + "span-panel-api>=3.0.0b4,<4.0", # Only this distribution depends on the eBus SDK. The bootstrap and # schema-0 stay clean, so a flat-panel install never pulls it in — which is # what bounds the release coupling this dependency introduces to panels on diff --git a/pyproject.toml b/pyproject.toml index 0a41437..d1fb391 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "3.0.0b3" +version = "3.0.0b4" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} diff --git a/uv.lock b/uv.lock index 1166fa7..6272d49 100644 --- a/uv.lock +++ b/uv.lock @@ -519,7 +519,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -607,7 +607,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -1323,7 +1323,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "3.0.0b3" +version = "3.0.0b4" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -1380,7 +1380,7 @@ dev = [ [[package]] name = "span-panel-api-schema-0" -version = "1.0.0b3" +version = "1.0.0b4" source = { editable = "packages/schema-0" } dependencies = [ { name = "span-panel-api" }, @@ -1391,7 +1391,7 @@ requires-dist = [{ name = "span-panel-api", editable = "." }] [[package]] name = "span-panel-api-schema-1" -version = "0.1.0b3" +version = "0.1.0b4" source = { editable = "packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From f89ba8a2f45169f63e3c894bd6d0c773325fd702 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:15:15 -0700 Subject: [PATCH 075/115] feat(metadata): distinguish missing property from absent hardware Adds FieldMetadata.resolved so consumers stop inferring the difference from telemetry. Defaulted field on a bootstrap dataclass: no protocol member, no ADAPTER_CONTRACT bump. Presence is classified at (device type, node) granularity in schema_1 and through the lugs fallback in schema_0, so each mirrors the lookup rule it guards rather than a coarser device- or type-level test. --- .../span_panel_api_schema_0/field_metadata.py | 18 +++ .../span_panel_api_schema_1/field_metadata.py | 28 ++++ src/span_panel_api/models.py | 14 ++ tests/test_field_metadata.py | 120 ++++++++++++++++++ tests/test_schema_zero_adapter.py | 32 +++++ 5 files changed, 212 insertions(+) diff --git a/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py b/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py index 418c9d2..c8aadc8 100644 --- a/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py +++ b/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py @@ -148,6 +148,21 @@ def _lookup_property( return None +def _type_declared(schema_types: HomieSchemaTypes, node_type: str) -> bool: + """Whether the schema carries a type block a property could have come from. + + Presence follows the same path `_lookup_property` does, fallback included: + firmware that publishes only the generic `…device.lugs` block still answers + for the typed rows, so a property dropped from it is a drop and not absent + hardware. There is no node dimension here — schema_0's rows address the + type-level REST schema directly — so the type block is the whole question. + """ + if isinstance(schema_types.get(node_type), dict): + return True + fallback_type = _LUGS_FALLBACK.get(node_type) + return fallback_type is not None and isinstance(schema_types.get(fallback_type), dict) + + def build_field_metadata( schema_types: HomieSchemaTypes, ) -> dict[str, FieldMetadata]: @@ -168,6 +183,9 @@ def build_field_metadata( for node_type, property_id, field_path in _PROPERTY_FIELD_MAP: prop_def = _lookup_property(schema_types, node_type, property_id) if prop_def is None: + if _type_declared(schema_types, node_type): + # The type block exists and omits the property — a genuine drop. + result[field_path] = FieldMetadata(unit=None, datatype="unknown", resolved=False) continue raw_unit = prop_def.get("unit") diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index c1be8ad..f0982f7 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -116,12 +116,23 @@ def build_field_metadata(devices: list[DiscoveredDevice]) -> dict[str, FieldMeta invented unit would validate a reading the panel never sends. """ declared: dict[str, tuple[str | None, str]] = {} + # Presence is a (device type, node) question, not a device question. The + # power-flows rows are (TYPE_PANEL, NODE_POWER_FLOWS, ...) and the panel + # device is always present, so a device-level test would mark every + # panel.power_flow_* path unresolved on a panel that simply has no + # power-flows node. + # + # Collected from the node structure rather than from `declared`, because a + # node that declares no properties at all is exactly the degradation this + # is here to catch, and it contributes no `declared` keys to read back. + present_type_nodes: set[tuple[str, str]] = set() for device in devices: description: dict[str, object] = device.description or {} device_type = str(description.get("type") or "") if not device_type: continue for node_id, node in _nodes(description).items(): + present_type_nodes.add((device_type, node_id)) for property_id, definition in _properties(node).items(): declared[f"{device_type}|{node_id}|{property_id}"] = ( _optional_str(definition.get("unit")), @@ -134,10 +145,27 @@ def build_field_metadata(devices: list[DiscoveredDevice]) -> dict[str, FieldMeta if found is not None: unit, datatype = found metadata[field_path] = FieldMetadata(unit=unit, datatype=datatype) + elif _node_declared(present_type_nodes, device_type, node_id): + # The node is here and does not declare the property: a real gap, + # distinct from the hardware simply not being installed. + metadata[field_path] = FieldMetadata(unit=None, datatype="unknown", resolved=False) metadata.update(_downstream_lugs_metadata(devices)) return metadata +def _node_declared(present_type_nodes: set[tuple[str, str]], device_type: str, node_id: str) -> bool: + """Whether any present device of this type declares this node. + + Mirrors `_lookup`'s subtype rule: a map row for `...lugs` matches a device + typed `...lugs.upstream`. Without this, typed-lugs firmware that dropped a + property would misclassify as absent hardware. + """ + return any( + node == node_id and (declared_device_type == device_type or declared_device_type.startswith(f"{device_type}.")) + for declared_device_type, node in present_type_nodes + ) + + _DOWNSTREAM_LUGS_FIELDS: tuple[tuple[str, str], ...] = ( (PROP_ACTIVE_POWER, "panel.feedthrough_power_w"), (PROP_IMPORTED_ENERGY, "panel.feedthrough_energy_consumed_wh"), diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 7b92903..4798afb 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -175,6 +175,20 @@ class FieldMetadata: unit: str | None # "W", "A", "V", "%", "kWh", None datatype: str # "float", "integer", "enum", "string", "boolean" + resolved: bool = True + """Whether a device declaring this field was actually found. + + Three-way contract with consumers: + + - entry present, ``resolved=True`` — the field is produced; ``unit`` is meaningful + - entry present, ``resolved=False`` — a device of the mapped type is in the + tree but does not declare the property. A real gap; ``unit`` is None. + - **no entry** — no device of that type. The hardware is not present. + + Defaulted so existing construction sites are unaffected. This is a + bootstrap dataclass, not a ``SchemaAdapter`` member, so adding it does not + invalidate built adapter wheels or bump ``ADAPTER_CONTRACT_VERSION``. + """ @dataclass(frozen=True, slots=True) diff --git a/tests/test_field_metadata.py b/tests/test_field_metadata.py index 3a85e38..a74857a 100644 --- a/tests/test_field_metadata.py +++ b/tests/test_field_metadata.py @@ -279,3 +279,123 @@ def test_non_dict_props_skipped(self, caplog: logging.LogCaptureFixture) -> None with caplog.at_level(logging.DEBUG): log_schema_drift(previous, current) assert "Schema drift" not in caplog.text + + +# --------------------------------------------------------------------------- +# Resolved vs. absent — the three-way contract +# +# A field path missing from the metadata used to be ambiguous: it could mean +# "this panel has no such hardware" or "the mapping dropped the property". +# `FieldMetadata.resolved` splits the two so consumers stop reconstructing the +# difference from telemetry. +# --------------------------------------------------------------------------- + + +def _device(device_id: str, type_: str, nodes: dict[str, object]) -> object: + """Minimal stand-in for ebus_sdk.DiscoveredDevice, which build_field_metadata + reads only via `.description`.""" + + class _D: + def __init__(self) -> None: + self.id = device_id + self.description = {"type": type_, "nodes": nodes} + + def get_property(self, node: str, prop: str) -> str | None: + """No published values: a description declares properties, it does + not carry readings. `find_lugs` reads `info/direction` through this, + so it must exist and must answer "unpublished".""" + return None + + return _D() + + +def test_present_device_missing_property_is_unresolved() -> None: + """A circuit device that declares no `meter` power property is a real gap, + not absent hardware — the integration must be able to tell them apart.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + circuit = _device( + device_id="c1", + type_="energy.ebus.device.circuit", + nodes={"info": {"properties": {"name": {"datatype": "string"}}}, "meter": {"properties": {}}}, + ) + metadata = build_schema_one([circuit]) + + entry = metadata["circuit.instant_power_w"] + assert entry.resolved is False + assert entry.unit is None + + +def test_a_node_declaring_no_properties_at_all_is_still_present() -> None: + """The boundary the presence rule has to get right in both directions. + + A `meter` node with an empty property set is the strongest form of the gap + — the node is there and declares nothing — while a circuit with no `meter` + node at all is a circuit that does not meter. Deriving presence from the + declared-property map would collapse the two, since neither contributes a + property to read back. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + unmetered = _device( + device_id="c1", + type_="energy.ebus.device.circuit", + nodes={"info": {"properties": {"name": {"datatype": "string"}}}}, + ) + + assert "circuit.instant_power_w" not in build_schema_one([unmetered]) + + +def test_absent_device_type_yields_no_entry() -> None: + """No BESS device means no battery entry at all — not an unresolved one.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + circuit = _device( + device_id="c1", + type_="energy.ebus.device.circuit", + nodes={"info": {"properties": {"name": {"datatype": "string"}}}}, + ) + metadata = build_schema_one([circuit]) + + assert "battery.soe_percentage" not in metadata + + +def test_absent_node_on_present_device_yields_no_entry() -> None: + """The panel device is always present, but a panel with no power-flows node + has no power-flow hardware — that must not read as degradation.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + panel = _device( + device_id="p1", + type_="energy.ebus.device.distribution-enclosure", + nodes={"info": {"properties": {"serial-number": {"datatype": "string"}}}}, + ) + metadata = build_schema_one([panel]) + + assert "panel.power_flow_pv" not in metadata + + +def test_present_node_missing_property_on_a_subtyped_device_is_unresolved() -> None: + """Presence must follow `_lookup`'s subtype rule. + + Firmware may declare `…device.lugs.upstream` where the map row says + `…device.lugs`. An exact-match presence test would read a dropped property + on typed-lugs firmware as absent hardware, which is the misclassification + this whole field exists to prevent. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + lugs = _device( + device_id="lugs-upstream", + type_="energy.ebus.device.lugs.upstream", + nodes={"meter": {"properties": {"active-power": {"datatype": "float", "unit": "W"}}}}, + ) + metadata = build_schema_one([lugs]) + + assert metadata["panel.instant_grid_power_w"] == FieldMetadata(unit="W", datatype="float") + assert metadata["panel.upstream_l1_current_a"].resolved is False + + +def test_resolved_defaults_true() -> None: + """Existing construction sites keep working unchanged.""" + assert FieldMetadata(unit="W", datatype="float").resolved is True diff --git a/tests/test_schema_zero_adapter.py b/tests/test_schema_zero_adapter.py index 48ab729..b567857 100644 --- a/tests/test_schema_zero_adapter.py +++ b/tests/test_schema_zero_adapter.py @@ -53,3 +53,35 @@ def test_dominant_power_source_topic_is_none_before_the_core_node_is_known( def test_is_not_ready_before_any_message(adapter: SchemaZeroAdapter) -> None: assert adapter.is_ready() is False + + +def test_schema_zero_marks_missing_property_unresolved() -> None: + """A type block that exists but drops a property is degradation, and must + not read the same as a type that is absent entirely.""" + from span_panel_api_schema_0.field_metadata import build_field_metadata + + types = {"energy.ebus.device.circuit": {"name": {"datatype": "string"}}} + metadata = build_field_metadata(types) + + assert metadata["circuit.instant_power_w"].resolved is False + assert metadata["circuit.instant_power_w"].unit is None + assert "battery.soe_percentage" not in metadata + + +def test_schema_zero_presence_follows_the_lugs_fallback() -> None: + """The lugs fallback is schema_0's equivalent of schema_1's subtype rule. + + Rows are keyed on the typed lugs variants, but firmware that publishes only + the generic `…device.lugs` block resolves through `_LUGS_FALLBACK`. Presence + has to use the same path, or a property dropped from a generic-lugs block + reads as absent hardware rather than as the drop it is. + """ + from span_panel_api_schema_0.field_metadata import build_field_metadata + + types = {"energy.ebus.device.lugs": {"active-power": {"datatype": "float", "unit": "W"}}} + metadata = build_field_metadata(types) + + assert metadata["panel.instant_grid_power_w"].resolved is True + assert metadata["panel.upstream_l1_current_a"].resolved is False + assert metadata["panel.downstream_l2_current_a"].resolved is False + assert "circuit.instant_power_w" not in metadata From 38ec6777536c0e909014bfe0ab6855e379c15aef Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:27:12 -0700 Subject: [PATCH 076/115] fix(metadata): apply the resolved contract to the downstream lugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five downstream lugs paths bypass _PROPERTY_FIELD_MAP — both lugs devices share type, node and properties, so the table can only address one direction — and resolved through a direction-matched lookup that kept the pre-change `continue`. A downstream device plainly in the tree, already resolving feedthrough_power_w from the same meter node, reported its dropped properties as absent hardware, so the two halves of panel.* answered to different rules. Fetching the meter node instead of defaulting it keeps "no meter node" absent, which a `.get(NODE_METER, {})` default would have collapsed into the gap case. --- .../span_panel_api_schema_1/field_metadata.py | 15 +- tests/test_field_metadata.py | 136 +++++++++++++++++- 2 files changed, 143 insertions(+), 8 deletions(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index f0982f7..462adfa 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -193,16 +193,29 @@ def _downstream_lugs_metadata(devices: list[DiscoveredDevice]) -> dict[str, Fiel Uses the same `find_lugs` the snapshot mapper uses, so the metadata and the value can never disagree about which device is which. + + Carries the same three-way contract as the table-driven loop, on the same + (device, node) granularity: no downstream device or no `meter` node on it + means no entry, while a `meter` node that omits a property is a declared + gap. Without that arm these five paths would report absent hardware for a + device already resolving its siblings from the same node — and the upstream + and downstream halves of `panel.*` would answer to different rules. """ downstream = find_lugs([d for d in devices if declared_type(d).startswith(TYPE_LUGS)], upstream=False) if downstream is None: return {} - declared = _properties(_nodes(downstream.description or {}).get(NODE_METER, {})) + nodes = _nodes(downstream.description or {}) + meter = nodes.get(NODE_METER) + if meter is None: + return {} + + declared = _properties(meter) found: dict[str, FieldMetadata] = {} for property_id, field_path in _DOWNSTREAM_LUGS_FIELDS: definition = declared.get(property_id) if definition is None: + found[field_path] = FieldMetadata(unit=None, datatype="unknown", resolved=False) continue found[field_path] = FieldMetadata( unit=_optional_str(definition.get("unit")), diff --git a/tests/test_field_metadata.py b/tests/test_field_metadata.py index a74857a..4ec9258 100644 --- a/tests/test_field_metadata.py +++ b/tests/test_field_metadata.py @@ -291,9 +291,20 @@ def test_non_dict_props_skipped(self, caplog: logging.LogCaptureFixture) -> None # --------------------------------------------------------------------------- -def _device(device_id: str, type_: str, nodes: dict[str, object]) -> object: - """Minimal stand-in for ebus_sdk.DiscoveredDevice, which build_field_metadata - reads only via `.description`.""" +def _device( + device_id: str, + type_: str, + nodes: dict[str, object], + values: dict[str, dict[str, str]] | None = None, +) -> object: + """Minimal stand-in for ebus_sdk.DiscoveredDevice. + + `build_field_metadata` reads declarations via `.description`, but the + downstream-lugs path resolves the device by its published `info/direction` + *value*, which is a different thing from declaring the property. `values` + supplies those readings, keyed node → property; anything unlisted reads as + unpublished. + """ class _D: def __init__(self) -> None: @@ -301,14 +312,34 @@ def __init__(self) -> None: self.description = {"type": type_, "nodes": nodes} def get_property(self, node: str, prop: str) -> str | None: - """No published values: a description declares properties, it does - not carry readings. `find_lugs` reads `info/direction` through this, - so it must exist and must answer "unpublished".""" - return None + return (values or {}).get(node, {}).get(prop) return _D() +_FULL_LUGS_METER: dict[str, object] = { + "properties": { + "active-power": {"datatype": "float", "unit": "W"}, + "imported-energy": {"datatype": "float", "unit": "Wh"}, + "exported-energy": {"datatype": "float", "unit": "Wh"}, + "current-a": {"datatype": "float", "unit": "A"}, + "current-b": {"datatype": "float", "unit": "A"}, + } +} + + +def _lugs(device_id: str, direction: str, meter: dict[str, object] | None) -> object: + """A lugs device that publishes its direction, with `meter` as given. + + `meter=None` means the device declares no meter node at all — which is a + different claim from declaring one that lists nothing. + """ + nodes: dict[str, object] = {"info": {"properties": {"direction": {"datatype": "string"}}}} + if meter is not None: + nodes["meter"] = meter + return _device(device_id, "energy.ebus.device.lugs", nodes, values={"info": {"direction": direction}}) + + def test_present_device_missing_property_is_unresolved() -> None: """A circuit device that declares no `meter` power property is a real gap, not absent hardware — the integration must be able to tell them apart.""" @@ -399,3 +430,94 @@ def test_present_node_missing_property_on_a_subtyped_device_is_unresolved() -> N def test_resolved_defaults_true() -> None: """Existing construction sites keep working unchanged.""" assert FieldMetadata(unit="W", datatype="float").resolved is True + + +def test_downstream_lugs_missing_properties_are_unresolved_not_absent() -> None: + """The downstream lugs answer to the same contract as everything else. + + These five paths bypass `_PROPERTY_FIELD_MAP` — both lugs devices share + type, node and properties, so the table can only address one of them — and + resolve through a direction-matched lookup instead. That second path had + kept the pre-change `continue`, so a downstream device that was plainly in + the tree, and already resolving `feedthrough_power_w` from the very same + `meter` node, reported its dropped properties as absent hardware. + + The asymmetry is the sharper half of the defect: in this one tree the + upstream paths report a dropped property as `resolved=False` while the + downstream paths reported nothing, so a consumer applying one rule to + `panel.*` lugs fields got different semantics by direction. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + upstream = _lugs("lugs-upstream", "UPSTREAM", _FULL_LUGS_METER) + downstream = _lugs( + "lugs-downstream", + "DOWNSTREAM", + { + "properties": { + "active-power": {"datatype": "float", "unit": "W"}, + "exported-energy": {"datatype": "float", "unit": "Wh"}, + } + }, + ) + metadata = build_schema_one([upstream, downstream]) + + # Present on both devices: unchanged, and still carrying real units. + assert metadata["panel.instant_grid_power_w"] == FieldMetadata(unit="W", datatype="float") + assert metadata["panel.upstream_l1_current_a"] == FieldMetadata(unit="A", datatype="float") + assert metadata["panel.feedthrough_power_w"] == FieldMetadata(unit="W", datatype="float") + assert metadata["panel.feedthrough_energy_produced_wh"] == FieldMetadata(unit="Wh", datatype="float") + + # Dropped by a device that is present and declares the node: degradation. + for degraded in ( + "panel.feedthrough_energy_consumed_wh", + "panel.downstream_l1_current_a", + "panel.downstream_l2_current_a", + ): + assert degraded in metadata, f"{degraded} read as absent hardware for a device in the tree" + assert metadata[degraded] == FieldMetadata(unit=None, datatype="unknown", resolved=False) + + +def test_the_two_lugs_directions_classify_the_same_drop_the_same_way() -> None: + """Stated as an equality rather than two separate expectations. + + The two halves reach their metadata through different code — the table for + upstream, a direction-matched lookup for downstream — so nothing structural + keeps them agreeing. This fails if either side drifts. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + bare_meter: dict[str, object] = {"properties": {}} + metadata = build_schema_one( + [_lugs("lugs-upstream", "UPSTREAM", bare_meter), _lugs("lugs-downstream", "DOWNSTREAM", bare_meter)] + ) + + assert metadata["panel.upstream_l1_current_a"] == metadata["panel.downstream_l1_current_a"] + assert metadata["panel.upstream_l1_current_a"].resolved is False + + +def test_downstream_lugs_without_a_meter_node_yields_no_entry() -> None: + """The other side of the boundary, and the reason the node is fetched + rather than defaulted. + + A device with no `meter` node does not meter, so its paths are absent + hardware. Reading properties out of a `.get(NODE_METER, {})` default would + make that indistinguishable from a `meter` node listing nothing, and this + whole distinction turns on telling those apart. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + metadata = build_schema_one( + [_lugs("lugs-upstream", "UPSTREAM", _FULL_LUGS_METER), _lugs("lugs-downstream", "DOWNSTREAM", None)] + ) + + for absent in ( + "panel.feedthrough_power_w", + "panel.feedthrough_energy_consumed_wh", + "panel.feedthrough_energy_produced_wh", + "panel.downstream_l1_current_a", + "panel.downstream_l2_current_a", + ): + assert absent not in metadata, f"{absent} was described with no meter node to describe" + + assert metadata["panel.upstream_l1_current_a"] == FieldMetadata(unit="A", datatype="float") From acd66e60a7d240c0da32b034971e25bd907908f8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:41:01 -0700 Subject: [PATCH 077/115] fix(metadata): resolve lugs field metadata by direction, not by type _PROPERTY_FIELD_MAP is keyed (device type, node, property) and the two lugs devices match on all three, so whichever one _lookup reached first answered for the panel.upstream_* paths. A property the upstream device had dropped came back resolved=True with a real unit because the downstream device still declared it, and with no upstream device present at all the downstream one described the whole main meter as working hardware that was not installed. Both are false resolved=True, the failure mode this metadata exists to prevent and the one nothing reports: the integration validates against a unit for a reading that never arrives. The five upstream rows leave the table and both directions now run through one find_lugs-based helper, the same resolution the snapshot mapper uses, so a field's unit and its value can no longer come from different devices. The subtype rule moves with them into the startswith(TYPE_LUGS) filter; _lookup keeps its general form for the other mapped types and is now covered there. --- .../span_panel_api_schema_1/field_metadata.py | 104 +++++++---- tests/test_field_metadata.py | 166 +++++++++++++++++- 2 files changed, 225 insertions(+), 45 deletions(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index 462adfa..c72d327 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -68,13 +68,9 @@ (TYPE_PANEL, NODE_POWER_FLOWS, "grid", "panel.power_flow_grid"), (TYPE_PANEL, NODE_POWER_FLOWS, "site", "panel.power_flow_site"), # --- Lugs → panel.* ------------------------------------------------------ - # One row per property, not per direction: both lugs devices declare the - # same type, and which is which comes from `info/direction` at read time. - (TYPE_LUGS, NODE_METER, "active-power", "panel.instant_grid_power_w"), - (TYPE_LUGS, NODE_METER, "imported-energy", "panel.main_meter_energy_consumed_wh"), - (TYPE_LUGS, NODE_METER, "exported-energy", "panel.main_meter_energy_produced_wh"), - (TYPE_LUGS, NODE_METER, "current-a", "panel.upstream_l1_current_a"), - (TYPE_LUGS, NODE_METER, "current-b", "panel.upstream_l2_current_a"), + # Deliberately absent. Which device a lugs property belongs to comes from + # `info/direction` at read time, and a table keyed on (type, node, property) + # cannot express that — see `_DOWNSTREAM_LUGS_FIELDS` and `_lugs_metadata`. # --- Circuit ------------------------------------------------------------- (TYPE_CIRCUIT, NODE_INFO, "name", "circuit.name"), (TYPE_CIRCUIT, NODE_INFO, "spaces", "circuit.tabs"), @@ -149,16 +145,19 @@ def build_field_metadata(devices: list[DiscoveredDevice]) -> dict[str, FieldMeta # The node is here and does not declare the property: a real gap, # distinct from the hardware simply not being installed. metadata[field_path] = FieldMetadata(unit=None, datatype="unknown", resolved=False) - metadata.update(_downstream_lugs_metadata(devices)) + metadata.update(_lugs_metadata(devices, upstream=True, fields=_UPSTREAM_LUGS_FIELDS)) + metadata.update(_lugs_metadata(devices, upstream=False, fields=_DOWNSTREAM_LUGS_FIELDS)) return metadata def _node_declared(present_type_nodes: set[tuple[str, str]], device_type: str, node_id: str) -> bool: """Whether any present device of this type declares this node. - Mirrors `_lookup`'s subtype rule: a map row for `...lugs` matches a device - typed `...lugs.upstream`. Without this, typed-lugs firmware that dropped a - property would misclassify as absent hardware. + Mirrors `_lookup`'s subtype rule, and has to: presence and lookup must + agree about which devices answer for a row, or a subtyped device that + dropped a property would resolve through one and misclassify through the + other. Both are now exercised on non-lugs types, lugs having moved to a + direction-resolved lookup of their own. """ return any( node == node_id and (declared_device_type == device_type or declared_device_type.startswith(f"{device_type}.")) @@ -166,6 +165,37 @@ def _node_declared(present_type_nodes: set[tuple[str, str]], device_type: str, n ) +# The ten fields `_PROPERTY_FIELD_MAP` cannot address, and why it cannot. +# +# The table is keyed `(device type, node, property)`, and the two lugs devices +# match on all three — same `energy.ebus.device.lugs`, same `meter` node, same +# property names — differing only in the `info/direction` value they publish. A +# table keyed that way cannot hold two different answers, so it cannot describe +# these ten fields at all: it can only describe *a* lugs device and label the +# result with one direction's field paths. +# +# Doing that was wrong in both directions at once. Whichever device `_lookup` +# reached first answered for the `upstream_*` paths, so a property the upstream +# device had dropped came back `resolved=True`, with a real unit, on the strength +# of the downstream device declaring it — and with no upstream device present at +# all, the downstream one described the whole main meter as working hardware that +# was not installed. Both are the false `resolved=True` this metadata exists to +# make impossible, and the silent kind: the integration validates against a unit +# for a reading that never arrives, and nothing anywhere reports a fault. +# +# The snapshot mapper never had the problem, because it resolves the pair by +# direction and reads each (`panel.py`, `PanelFields.__init__`). Resolving the +# metadata the same way is what keeps the two from disagreeing about which device +# is which — the property a field's unit describes is now the same property whose +# value fills it. +_UPSTREAM_LUGS_FIELDS: tuple[tuple[str, str], ...] = ( + (PROP_ACTIVE_POWER, "panel.instant_grid_power_w"), + (PROP_IMPORTED_ENERGY, "panel.main_meter_energy_consumed_wh"), + (PROP_EXPORTED_ENERGY, "panel.main_meter_energy_produced_wh"), + (PROP_CURRENT_A, "panel.upstream_l1_current_a"), + (PROP_CURRENT_B, "panel.upstream_l2_current_a"), +) + _DOWNSTREAM_LUGS_FIELDS: tuple[tuple[str, str], ...] = ( (PROP_ACTIVE_POWER, "panel.feedthrough_power_w"), (PROP_IMPORTED_ENERGY, "panel.feedthrough_energy_consumed_wh"), @@ -173,46 +203,37 @@ def _node_declared(present_type_nodes: set[tuple[str, str]], device_type: str, n (PROP_CURRENT_A, "panel.downstream_l1_current_a"), (PROP_CURRENT_B, "panel.downstream_l2_current_a"), ) -"""The five fields the table above cannot address, and why it cannot. - -`_PROPERTY_FIELD_MAP` is keyed `(device type, node, property)`, and the two lugs -devices share all three — same `energy.ebus.device.lugs`, same `meter` node, same -properties — differing only in the `info/direction` value. So one row per property -is all the table can hold, and those rows go to the `upstream_*` paths. - -The snapshot mapper has never had this problem, because it resolves the two -devices by direction and reads each. That is why these five fields are *populated* -and yet carry no metadata: the values were right, and `schema_validation.py` had -nothing to check their units against — five sensors with no guard against a silent -unit change, in exactly the region the lugs fidelity gap makes least testable. -""" -def _downstream_lugs_metadata(devices: list[DiscoveredDevice]) -> dict[str, FieldMetadata]: - """Metadata for the downstream lugs, resolved by direction rather than by type. +def _lugs_metadata( + devices: list[DiscoveredDevice], *, upstream: bool, fields: tuple[tuple[str, str], ...] +) -> dict[str, FieldMetadata]: + """Metadata for one lugs device, resolved by direction rather than by type. Uses the same `find_lugs` the snapshot mapper uses, so the metadata and the value can never disagree about which device is which. Carries the same three-way contract as the table-driven loop, on the same - (device, node) granularity: no downstream device or no `meter` node on it - means no entry, while a `meter` node that omits a property is a declared - gap. Without that arm these five paths would report absent hardware for a - device already resolving its siblings from the same node — and the upstream - and downstream halves of `panel.*` would answer to different rules. + (device, node) granularity: no lugs device in this direction, or no `meter` + node on it, means no entry, while a `meter` node that omits a property is a + declared gap. Both directions run through here so the two halves of + `panel.*` cannot drift into answering to different rules. + + A lugs device that publishes no `info/direction` is invisible to `find_lugs` + and so yields no entry, which is deliberate: the mapper reads its values + through the same call, so nothing would populate those fields either. """ - downstream = find_lugs([d for d in devices if declared_type(d).startswith(TYPE_LUGS)], upstream=False) - if downstream is None: + lugs = find_lugs([d for d in devices if declared_type(d).startswith(TYPE_LUGS)], upstream=upstream) + if lugs is None: return {} - nodes = _nodes(downstream.description or {}) - meter = nodes.get(NODE_METER) + meter = _nodes(lugs.description or {}).get(NODE_METER) if meter is None: return {} declared = _properties(meter) found: dict[str, FieldMetadata] = {} - for property_id, field_path in _DOWNSTREAM_LUGS_FIELDS: + for property_id, field_path in fields: definition = declared.get(property_id) if definition is None: found[field_path] = FieldMetadata(unit=None, datatype="unknown", resolved=False) @@ -229,8 +250,15 @@ def _lookup( ) -> tuple[str | None, str] | None: """Find a declaration, allowing a device type to be a subtype of the mapped one. - Lugs are the reason: firmware may declare `…device.lugs` or a subtyped - `…device.lugs.upstream`, and both carry the same properties. + eBus device types are hierarchical and a subtype carries its parent's + properties, so a device typed `X.Y` satisfies a row written for `X`. + + Lugs were the observed instance — `…device.lugs` versus a subtyped + `…device.lugs.upstream` — and they no longer come through here, because + which lugs device a property belongs to is a direction question the table + cannot ask. The rule is kept for every other mapped type rather than + retired with its first user: the same subtyping applies to all of them, and + `_LUGS_FALLBACK` in the flat adapter is evidence SPAN does ship it. """ exact = declared.get(f"{device_type}|{node_id}|{property_id}") if exact is not None: diff --git a/tests/test_field_metadata.py b/tests/test_field_metadata.py index 4ec9258..ab5b550 100644 --- a/tests/test_field_metadata.py +++ b/tests/test_field_metadata.py @@ -407,19 +407,26 @@ def test_absent_node_on_present_device_yields_no_entry() -> None: def test_present_node_missing_property_on_a_subtyped_device_is_unresolved() -> None: - """Presence must follow `_lookup`'s subtype rule. - - Firmware may declare `…device.lugs.upstream` where the map row says - `…device.lugs`. An exact-match presence test would read a dropped property - on typed-lugs firmware as absent hardware, which is the misclassification - this whole field exists to prevent. + """The subtype rule, which survived the move to a direction-resolved lookup. + + Firmware may declare `…device.lugs.upstream` where the code says + `…device.lugs`. That used to be `_lookup`'s prefix fallback; the lugs paths + no longer go through the table, so the rule now lives in the + `startswith(TYPE_LUGS)` filter that feeds `find_lugs`. Either way an + exact-match test would read a dropped property on typed-lugs firmware as + absent hardware, which is the misclassification this field exists to + prevent — so the expectation is unchanged and only its mechanism moved. """ from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one lugs = _device( device_id="lugs-upstream", type_="energy.ebus.device.lugs.upstream", - nodes={"meter": {"properties": {"active-power": {"datatype": "float", "unit": "W"}}}}, + nodes={ + "info": {"properties": {"direction": {"datatype": "string"}}}, + "meter": {"properties": {"active-power": {"datatype": "float", "unit": "W"}}}, + }, + values={"info": {"direction": "UPSTREAM"}}, ) metadata = build_schema_one([lugs]) @@ -427,6 +434,35 @@ def test_present_node_missing_property_on_a_subtyped_device_is_unresolved() -> N assert metadata["panel.upstream_l1_current_a"].resolved is False +def test_the_subtype_rule_holds_for_both_lugs_directions() -> None: + """Both halves, since each resolves its own device through the filter.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + def _typed(device_id: str, type_: str, direction: str) -> object: + return _device( + device_id, + type_, + { + "info": {"properties": {"direction": {"datatype": "string"}}}, + "meter": {"properties": {"current-a": {"datatype": "float", "unit": "A"}}}, + }, + values={"info": {"direction": direction}}, + ) + + metadata = build_schema_one( + [ + _typed("u", "energy.ebus.device.lugs.upstream", "UPSTREAM"), + _typed("d", "energy.ebus.device.lugs.downstream", "DOWNSTREAM"), + ] + ) + + assert metadata["panel.upstream_l1_current_a"] == FieldMetadata(unit="A", datatype="float") + assert metadata["panel.downstream_l1_current_a"] == FieldMetadata(unit="A", datatype="float") + # And the drop classification still reaches subtyped devices. + assert metadata["panel.instant_grid_power_w"].resolved is False + assert metadata["panel.feedthrough_power_w"].resolved is False + + def test_resolved_defaults_true() -> None: """Existing construction sites keep working unchanged.""" assert FieldMetadata(unit="W", datatype="float").resolved is True @@ -521,3 +557,119 @@ def test_downstream_lugs_without_a_meter_node_yields_no_entry() -> None: assert absent not in metadata, f"{absent} was described with no meter node to describe" assert metadata["panel.upstream_l1_current_a"] == FieldMetadata(unit="A", datatype="float") + + +def test_an_upstream_drop_is_not_masked_by_the_downstream_device() -> None: + """The failure mode `resolved` exists to prevent, in the one place the + lookup could not see it. + + `_lookup` keys on (type, node, property) and the two lugs devices match on + all three, so a property the *upstream* device dropped was still answered — + with a real unit — by the downstream device that still declared it. A gap + reported as fine, which is strictly worse than the inverse: a false + `resolved=False` shows up as a repair someone can see, while a false + `resolved=True` lets the sensor die silently with nothing to flag it. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + upstream = _lugs( + "lugs-upstream", + "UPSTREAM", + { + "properties": { + "active-power": {"datatype": "float", "unit": "W"}, + "imported-energy": {"datatype": "float", "unit": "Wh"}, + "exported-energy": {"datatype": "float", "unit": "Wh"}, + "current-b": {"datatype": "float", "unit": "A"}, + } + }, + ) + downstream = _lugs("lugs-downstream", "DOWNSTREAM", _FULL_LUGS_METER) + metadata = build_schema_one([upstream, downstream]) + + assert metadata["panel.upstream_l1_current_a"] == FieldMetadata(unit=None, datatype="unknown", resolved=False) + # The downstream device still declares it, and still resolves it — the point + # is that its declaration must not answer for the other device. + assert metadata["panel.downstream_l1_current_a"] == FieldMetadata(unit="A", datatype="float") + # Everything the upstream device does declare is untouched. + assert metadata["panel.upstream_l2_current_a"] == FieldMetadata(unit="A", datatype="float") + assert metadata["panel.instant_grid_power_w"] == FieldMetadata(unit="W", datatype="float") + + +def test_a_downstream_drop_is_not_masked_by_the_upstream_device() -> None: + """The mirror, held separately because the two directions reach their + metadata through the same helper only after this change — and a later edit + that re-tables one direction would break exactly one of the pair.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + upstream = _lugs("lugs-upstream", "UPSTREAM", _FULL_LUGS_METER) + downstream = _lugs( + "lugs-downstream", + "DOWNSTREAM", + { + "properties": { + "active-power": {"datatype": "float", "unit": "W"}, + "imported-energy": {"datatype": "float", "unit": "Wh"}, + "exported-energy": {"datatype": "float", "unit": "Wh"}, + "current-b": {"datatype": "float", "unit": "A"}, + } + }, + ) + metadata = build_schema_one([upstream, downstream]) + + assert metadata["panel.downstream_l1_current_a"] == FieldMetadata(unit=None, datatype="unknown", resolved=False) + assert metadata["panel.upstream_l1_current_a"] == FieldMetadata(unit="A", datatype="float") + + +def test_no_upstream_device_yields_no_entry() -> None: + """The upstream half of the contract's third case, matching the downstream + one: absent hardware is absent, not degraded.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + metadata = build_schema_one([_lugs("lugs-downstream", "DOWNSTREAM", _FULL_LUGS_METER)]) + + for absent in ( + "panel.instant_grid_power_w", + "panel.main_meter_energy_consumed_wh", + "panel.main_meter_energy_produced_wh", + "panel.upstream_l1_current_a", + "panel.upstream_l2_current_a", + ): + assert absent not in metadata, f"{absent} was described with no upstream device to describe" + + assert metadata["panel.downstream_l1_current_a"] == FieldMetadata(unit="A", datatype="float") + + +def test_the_subtype_rule_applies_beyond_lugs() -> None: + """`_lookup` and `_node_declared` keep a general subtype rule, so cover it + generally. + + A device typed `X.Y` satisfies a row written for `X`, because eBus types are + hierarchical and a subtype carries its parent's properties. Lugs were the + only instance exercising it until they moved to a direction-resolved + lookup; without a non-lugs case the rule would now be both untested and + invisible, and the next reader would be entitled to delete it. + + Both halves are asserted together on purpose: resolution and presence have + to agree about which devices answer for a row, or a subtyped device that + dropped a property resolves through one and misclassifies through the other. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + subtyped_circuit = _device( + device_id="c1", + type_="energy.ebus.device.circuit.branch", + nodes={ + "meter": {"properties": {"active-power": {"datatype": "float", "unit": "W"}}}, + "breaker": {"properties": {}}, + }, + ) + metadata = build_schema_one([subtyped_circuit]) + + # Resolution reaches the subtype. + assert metadata["circuit.instant_power_w"] == FieldMetadata(unit="W", datatype="float") + # Presence reaches it too: declared nodes that omit a property are gaps... + assert metadata["circuit.current_a"].resolved is False + assert metadata["circuit.breaker_rating_a"].resolved is False + # ...while a node the subtype never declares stays absent. + assert "circuit.relay_state" not in metadata From 3b2fda7dde201aab9d40f8af59eb9d138f94b673 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:45:10 -0700 Subject: [PATCH 078/115] docs(metadata): state the third meaning of "no entry", and pin it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolving the lugs pair by direction gave "no entry" a case the contract did not describe: a lugs device is in the tree but publishes no info/direction, so it fills neither role and gets no entry rather than an unresolved one. Right, because the snapshot mapper resolves the pair through the same call and populates nothing for it either — but consumers were reading a contract that said "no entry" means the hardware is not present. The behaviour had no test after the subtype test gained a published direction. It has one now, asserting all ten lugs paths are absent, so an edit that "repairs" the case to resolved=False fails rather than shipping a promise that a nonexistent field is degraded. --- src/span_panel_api/models.py | 12 ++++++++- tests/test_field_metadata.py | 51 ++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 4798afb..0a87682 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -183,7 +183,17 @@ class FieldMetadata: - entry present, ``resolved=True`` — the field is produced; ``unit`` is meaningful - entry present, ``resolved=False`` — a device of the mapped type is in the tree but does not declare the property. A real gap; ``unit`` is None. - - **no entry** — no device of that type. The hardware is not present. + - **no entry** — no device of that type, or none identifiable for that role. + Nothing will populate the field. + + The second half of that last case is the lugs pair. Both devices declare the + same type and the same ``meter`` node, so which one feeds ``panel.upstream_*`` + and which feeds ``panel.feedthrough_*`` / ``panel.downstream_*`` is decided by + the ``info/direction`` value they publish. A lugs device that publishes no + direction fills neither role, and gets no entry rather than an unresolved one + — deliberately, because the snapshot mapper resolves the pair through the same + call and populates nothing for it either. An unresolved entry would promise a + field that is degraded; there is no such field to degrade. Defaulted so existing construction sites are unaffected. This is a bootstrap dataclass, not a ``SchemaAdapter`` member, so adding it does not diff --git a/tests/test_field_metadata.py b/tests/test_field_metadata.py index ab5b550..f9fce2b 100644 --- a/tests/test_field_metadata.py +++ b/tests/test_field_metadata.py @@ -673,3 +673,54 @@ def test_the_subtype_rule_applies_beyond_lugs() -> None: assert metadata["circuit.breaker_rating_a"].resolved is False # ...while a node the subtype never declares stays absent. assert "circuit.relay_state" not in metadata + + +def test_a_lugs_device_without_a_published_direction_yields_no_entry() -> None: + """A deliberate behaviour change from the move to direction-resolved lugs, + pinned so a later edit trips over the decision rather than the report. + + `find_lugs` identifies the pair by the `info/direction` value each device + publishes, and skips a device that publishes none. Such a device therefore + fills neither role and gets no entry at all — not an unresolved one. + + This is the contract's "or none identifiable for that role", and it is + right rather than merely tolerable: the snapshot mapper resolves the pair + through the same call, so nothing populates these ten fields either. Before + the lugs paths left `_PROPERTY_FIELD_MAP` the five `upstream_*` paths came + back `resolved=True` with real units here, which was the worst available + answer — a unit advertised for a reading that provably never arrives, with + nothing anywhere to flag it. + + An unresolved entry would be the wrong repair, and that is the edit this + test exists to catch: `resolved=False` promises a field that exists and is + degraded, and there is no such field to degrade until the device says which + one it is. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + directionless = _device( + device_id="lugs-1", + type_="energy.ebus.device.lugs", + nodes={ + "info": {"properties": {"direction": {"datatype": "string"}}}, + "meter": _FULL_LUGS_METER, + }, + ) + metadata = build_schema_one([directionless]) + + for absent in ( + "panel.instant_grid_power_w", + "panel.main_meter_energy_consumed_wh", + "panel.main_meter_energy_produced_wh", + "panel.upstream_l1_current_a", + "panel.upstream_l2_current_a", + "panel.feedthrough_power_w", + "panel.feedthrough_energy_consumed_wh", + "panel.feedthrough_energy_produced_wh", + "panel.downstream_l1_current_a", + "panel.downstream_l2_current_a", + ): + assert absent not in metadata, ( + f"{absent} was described for a lugs device that publishes no direction. " + "No entry is the contract here: the mapper cannot populate it either." + ) From 723e9416c037b7a38324a3840c50f9e2877a16a3 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:56:04 -0700 Subject: [PATCH 079/115] feat: ship the captured wire payloads as package data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consumers outside this repository need a real schema document and a real device tree to check an adapter's output against, and the only way to get one was to copy the file. A copy has no version: it goes stale in silence, and the Home Assistant integration's conformance gate then verifies its declarations against a schema no panel runs. Detecting that is not possible from the integration's CI, which installs these distributions from PyPI and has no checkout to compare against. So stop making staleness detectable and make it impossible. Each payload now ships in the wheel of the distribution that can interpret it, read through an accessor rather than by path: span_panel_api.reference_payloads homie_schema(), homie_schema_types() span_panel_api_schema_1.reference_payloads parent_child_tree(), devices_from_tree(), device_from_topics() The schema document belongs to the bootstrap because it is the response of get_homie_schema() here, modelled by V2HomieSchema here, and dispatch reads its data_model_version to decide which adapter parses the panel at all. The retained-topic tree belongs to schema-1 because a tree is only interpretable by the parser that speaks its vocabulary, and the eBus SDK that replays it into devices is that distribution's dependency alone. devices_from_tree ships with the capture rather than being left to callers: four test modules here held the same twelve lines of replay, and the integration held a fifth copy with a comment naming the test it mirrored. Shipping the tree without the replay would just relocate that duplication. It takes the tree rather than reading it, so a consumer can filter the capture first and still build devices the same way. This suite now reads both payloads through the same accessors, so the shipped artifact is the thing under test: the schema anchor in test_schema_provenance is checked against the bytes a consumer installs, not against a file that exists only in a checkout. Bytes are unchanged — this is a delivery-mechanism change. Versions move to 3.0.0b5 and schema-1 0.1.0b5; schema-0 has no content change and is not released. schema-1's floor stays at span-panel-api>=3.0.0b4, since nothing added here reaches for anything newer. --- CHANGELOG.md | 16 ++++ README.md | 24 ++++++ packages/schema-1/CHANGELOG.md | 14 ++++ packages/schema-1/README.md | 13 +++ packages/schema-1/pyproject.toml | 2 +- .../reference_payloads/README.md | 10 +++ .../reference_payloads/__init__.py | 82 +++++++++++++++++++ .../parent_child_tree.json | 0 pyproject.toml | 2 +- scripts/verify_reconnect.py | 2 +- .../reference_payloads/README.md | 23 ++++++ .../reference_payloads/__init__.py | 66 +++++++++++++++ .../reference_payloads}/homie_schema.json | 0 tests/fixtures/v2/README.md | 25 ++---- tests/test_detection_auth.py | 9 +- tests/test_schema_one_adapter.py | 4 +- tests/test_schema_one_against_simulator.py | 4 +- tests/test_schema_one_circuits.py | 23 ++---- tests/test_schema_one_devices.py | 18 +--- tests/test_schema_one_panel.py | 20 ++--- tests/test_schema_one_snapshot.py | 18 +--- tests/test_schema_provenance.py | 16 ++-- uv.lock | 4 +- 23 files changed, 291 insertions(+), 104 deletions(-) create mode 100644 packages/schema-1/src/span_panel_api_schema_1/reference_payloads/README.md create mode 100644 packages/schema-1/src/span_panel_api_schema_1/reference_payloads/__init__.py rename {tests/fixtures => packages/schema-1/src/span_panel_api_schema_1/reference_payloads}/parent_child_tree.json (100%) create mode 100644 src/span_panel_api/reference_payloads/README.md create mode 100644 src/span_panel_api/reference_payloads/__init__.py rename {tests/fixtures/v2 => src/span_panel_api/reference_payloads}/homie_schema.json (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1134bb9..73e6467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0b5] - 08/2026 + +Pre-release. Publishes the captured schema document consumers were copying by hand. + +### Added + +- **`span_panel_api.reference_payloads`, shipping `homie_schema.json` as package data.** The captured `GET /api/v2/homie/schema` response moves out of `tests/fixtures/v2/` and into the wheel, reached by `homie_schema()` and `homie_schema_types()` rather + than by path. It was already being consumed outside this repository: the Home Assistant integration checks the field paths it declares against what an adapter can actually produce, which needs a real schema document, so it vendored a byte copy with a + README explaining where the copy came from. A copy has no version — it goes stale in silence, and a stale one turns the integration's conformance gate into a check against a schema no panel runs. Shipped, the payload carries the version of the release it + came with: pin `span-panel-api==3.0.0b5` and you read the bytes that release was written against, with nothing left to keep in sync. `homie_schema_types()` returns `HomieSchemaTypes` — precisely what + `span_panel_api_schema_0.field_metadata.build_field_metadata` accepts — so a caller building metadata never reaches into an untyped document to get it. This distribution owns the schema document rather than an adapter one because it is the response of + `get_homie_schema()` here, modelled by `V2HomieSchema` here, and dispatch reads its `data_model_version` to decide which adapter parses the panel at all. The parent/child device tree is the other half and ships from `span-panel-api-schema-1`, with the + parser that can interpret it. +- **This suite reads the payload through the same accessor.** `test_schema_provenance.py` and `test_detection_auth.py` no longer open a path, so the schema anchor is checked against the bytes a consumer installs rather than against a file that exists only + in a checkout. + ## [3.0.0b3] - 08/2026 Pre-release. Normalises DER identity onto v1.0's vocabulary, and stops deriving the grid answers that v1.0 states outright. diff --git a/README.md b/README.md index 199f321..9ceb8e9 100644 --- a/README.md +++ b/README.md @@ -377,6 +377,29 @@ The `PanelCapability` flag enum advertises transport features at runtime: | `CIRCUIT_CONTROL` | Can set relay state and shed priority | | `BATTERY_SOE` | Battery state-of-energy available | +## Reference Payloads + +Captures of what a panel actually serves, shipped as package data so a consumer can check its own assumptions against real bytes without vendoring a copy that silently goes stale: + +```python +from span_panel_api.reference_payloads import homie_schema, homie_schema_types + +document = homie_schema() # the captured GET /api/v2/homie/schema response +types = homie_schema_types() # its `types` map, typed as HomieSchemaTypes +``` + +`homie_schema_types()` returns exactly what `span_panel_api_schema_0.field_metadata.build_field_metadata` accepts, so building real adapter metadata to compare against is two lines and no file handling. + +The parent/child device tree is the schema_1 counterpart and ships from that adapter, with the parser that can interpret it: + +```python +from span_panel_api_schema_1.reference_payloads import devices_from_tree, parent_child_tree + +devices = devices_from_tree(parent_child_tree()) +``` + +Each payload carries the version of the release it shipped in. Pin a version and you read the bytes that version was written against. + ## Project Structure ```text @@ -390,6 +413,7 @@ src/span_panel_api/ ├── models.py # Snapshot dataclasses (panel, circuit, battery, PV) ├── phase_validation.py # Electrical phase utilities ├── protocol.py # PEP 544 protocols + PanelCapability flags +├── reference_payloads/ # Captured wire payloads shipped as package data └── mqtt/ ├── __init__.py ├── accumulator.py # HomiePropertyAccumulator (Homie v5 protocol layer) diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index 6234205..af8c06e 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -7,6 +7,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is the parent/child device tree SPAN firmware `r202633+` publishes, identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version number. A release here means this parser changed, never that the panel did. +## [0.1.0b5] - 08/2026 + +Pre-release. Requires `span-panel-api` 3.0.0b4 or newer — unchanged, because nothing added here reaches for anything newer. + +### Added + +- **`span_panel_api_schema_1.reference_payloads`, shipping `parent_child_tree.json` as package data.** The captured retained-topic tree of a full 40-space panel moves out of the repository's `tests/fixtures/` and into the wheel, reached by + `parent_child_tree()` rather than by path. The reason is the same one that put the schema document in the bootstrap's wheel: consumers outside this repository need a real capture to check an adapter's output against, and the only alternative to shipping + one is vendoring a byte copy that has no version and goes stale in silence. It ships from _this_ distribution rather than the bootstrap because a retained topic tree is only interpretable by the parser that speaks its vocabulary — and the eBus SDK that + turns it back into devices is this distribution's dependency alone. +- **`devices_from_tree` and `device_from_topics`.** A tree is not directly usable: every consumer has to replay the retained topics through `DiscoveredDevice` first, and that replay is this parser's own knowledge of how the transport feeds it. Shipping the + capture without the replay would just move a copy of that logic into every consumer, which is the burden the package data exists to remove — four test modules here held the same twelve lines, and the Home Assistant integration held a fifth copy with a + comment naming the test it was mirrored from. `devices_from_tree` takes the tree rather than reading it, so a consumer can filter the capture first — dropping the BESS to model a panel that has none — and still build devices the same way. + ## [0.1.0b3] - 08/2026 Pre-release. **Requires `span-panel-api` 3.0.0b3 or newer** — see Fixed. diff --git a/packages/schema-1/README.md b/packages/schema-1/README.md index a872262..9ac4b51 100644 --- a/packages/schema-1/README.md +++ b/packages/schema-1/README.md @@ -6,3 +6,16 @@ Parent/child schema parser (`data-model-version` 1.x, SPAN firmware r202633+) fo parser can build a snapshot. What exists today is `BridgeControllerTransport` — an `ebus_sdk.MqttControllerTransport` backed by span-panel-api's own MQTT connection, so the eBus SDK can parse the parent/child tree while the connection to the panel's broker stays ours. + +## Reference payloads + +A retained-topic capture of a full 40-space parent/child panel ships as package data, with the replay that turns it back into devices: + +```python +from span_panel_api_schema_1.reference_payloads import devices_from_tree, parent_child_tree + +devices = devices_from_tree(parent_child_tree()) +``` + +It ships here rather than from the bootstrap because a retained topic tree is only interpretable by the parser that speaks its vocabulary, and the eBus SDK is this distribution's dependency alone. `devices_from_tree` takes the tree rather than reading it, +so a consumer can filter the capture first — dropping the BESS to model a panel that has none — and still build devices the same way. The bootstrap ships the schema document it fetches; see `span_panel_api.reference_payloads`. diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index 4377755..04d323e 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api-schema-1" -version = "0.1.0b4" +version = "0.1.0b5" description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api" authors = [ {name = "SpanPanel"} diff --git a/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/README.md b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/README.md new file mode 100644 index 0000000..1249232 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/README.md @@ -0,0 +1,10 @@ +# Reference payloads + +Shipped as package data and read through `span_panel_api_schema_1.reference_payloads`, never by path — a consumer that installs this distribution gets these bytes, and a consumer that pins a version gets the bytes that version's parser was written against. + +## `parent_child_tree.json` + +Retained topics captured off a `panel_sim` parent/child tree for a 40-space panel: 13 devices — the panel, both lugs, a BESS with its MID, a PV, an EVSE, and the circuits. + +Shape is `{device_id: {topic: payload}}`, every value a string, exactly as the broker retains them. `$description` is therefore a **JSON string**, not a nested object; `device_from_topics` replays it the way the transport does. `bess-mid` is typed +`energy.ebus.device.mid`, not `.bess` — a consumer filtering the tree by type marker has to expect the MID to survive a BESS filter. diff --git a/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/__init__.py b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/__init__.py new file mode 100644 index 0000000..9502e51 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/__init__.py @@ -0,0 +1,82 @@ +"""Reference wire payloads for the parent/child schema, shipped as package data. + +The counterpart to `span_panel_api.reference_payloads`, and here rather than +there for the reason that decides every placement in this workspace: a retained +topic tree is only interpretable by the parser that speaks its vocabulary, and +the eBus SDK that turns it back into devices is this distribution's dependency +alone. The bootstrap ships the document it fetches; this ships the tree it +cannot read. + +`devices_from_tree` is exported alongside the capture because a tree is not +directly usable — every consumer of it has to replay the retained topics +through `DiscoveredDevice` first, and that replay is the parser's own knowledge +of how the transport feeds it. Shipping the capture without the replay just +moves a copy of this module's logic into every consumer, which is the burden +the package data exists to remove. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from importlib import resources +import json +from typing import TypeAlias + +from ebus_sdk.homie import DiscoveredDevice + +RetainedTopicTree: TypeAlias = Mapping[str, Mapping[str, str]] +"""A retained-topic capture: device id -> topic -> payload, all strings. + +`$description` is a JSON *string*, not a nested object — it is stored on the +wire exactly as the panel publishes it, and `update_description` parses it. +""" + +_PACKAGE = "span_panel_api_schema_1.reference_payloads" +_PARENT_CHILD_TREE = "parent_child_tree.json" + +_DEFAULT_STATE = "ready" +_DOMAIN = "ebus" + + +def parent_child_tree() -> RetainedTopicTree: + """The captured retained topics of a full 40-space panel. + + Thirteen devices: the panel, both lugs, a BESS with its MID, a PV, an EVSE + and the circuits — enough that a consumer can check what each device class + does and does not declare, including the absences. + """ + text = resources.files(_PACKAGE).joinpath(_PARENT_CHILD_TREE).read_text(encoding="utf-8") + tree: object = json.loads(text) + if not isinstance(tree, dict): + raise TypeError(f"{_PARENT_CHILD_TREE} is not a JSON object") + return tree + + +def device_from_topics(device_id: str, topics: Mapping[str, str]) -> DiscoveredDevice: + """Rebuild one discovered device from its retained topics. + + The same sequence the transport performs on a broker replay: describe, + state, then every non-`$` topic as a `node/property` value. A device with no + `$state` retained is treated as ready, which is what the transport assumes + for a device that described itself. + """ + device = DiscoveredDevice(device_id, _DOMAIN) + device.update_description(topics["$description"]) + device.update_state(topics.get("$state", _DEFAULT_STATE)) + for topic, value in topics.items(): + if topic.startswith("$"): + continue + node, _, prop = topic.partition("/") + if prop: + device.update_property(node, prop, value) + return device + + +def devices_from_tree(tree: RetainedTopicTree) -> list[DiscoveredDevice]: + """Rebuild every device in a capture. + + Takes the tree rather than reading it, so a consumer can filter the capture + first — dropping the BESS to model a panel that has none, say — and still + build devices the same way. + """ + return [device_from_topics(device_id, topics) for device_id, topics in tree.items()] diff --git a/tests/fixtures/parent_child_tree.json b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json similarity index 100% rename from tests/fixtures/parent_child_tree.json rename to packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json diff --git a/pyproject.toml b/pyproject.toml index d1fb391..f6eb5b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "3.0.0b4" +version = "3.0.0b5" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} diff --git a/scripts/verify_reconnect.py b/scripts/verify_reconnect.py index 8016be8..ef9c523 100644 --- a/scripts/verify_reconnect.py +++ b/scripts/verify_reconnect.py @@ -39,7 +39,7 @@ --broker-host 127.0.0.1 --broker-port 1883 --no-tls \ --data-model-version 1.0 \ --adapter span_panel_api_schema_1:SchemaOneAdapter \ - --seed tests/fixtures/parent_child_tree.json + --seed packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json Exits non-zero if any check fails. """ diff --git a/src/span_panel_api/reference_payloads/README.md b/src/span_panel_api/reference_payloads/README.md new file mode 100644 index 0000000..80d5fd1 --- /dev/null +++ b/src/span_panel_api/reference_payloads/README.md @@ -0,0 +1,23 @@ +# Reference payloads + +Shipped as package data and read through `span_panel_api.reference_payloads`, never by path — a consumer that installs this distribution gets these bytes, and a consumer that pins a version gets the bytes that version was written against. + +## `homie_schema.json` + +The `GET /api/v2/homie/schema` response, captured from a live SPAN Panel running firmware `spanos2/r202603/05`. Unauthenticated endpoint. Serial numbers are masked (last 4 chars replaced with `XXXX`). + +Schema hash `sha256:d347556a07d98f40` — compare against `typesSchemaHash` in a live response to detect a schema change across firmware versions. `span_panel_api_schema_0.const.SCHEMA_ANCHOR` is pinned to this value, and `tests/test_schema_provenance.py` +fails when the two diverge. + +### Node types present + +| Node Type | Properties | Notes | +| ------------------------------------------------ | ---------- | -------------------------------------------------- | +| `energy.ebus.device.distribution-enclosure.core` | 17 | Panel-wide state, network, hardware | +| `energy.ebus.device.lugs` | 7 | Upstream (main meter) and downstream (feedthrough) | +| `energy.ebus.device.circuit` | 16 | Per-circuit — one node per commissioned circuit | +| `energy.ebus.device.bess` | 12 | Battery — optional, only if commissioned | +| `energy.ebus.device.pv` | 7 | Solar — optional, only if commissioned | +| `energy.ebus.device.evse` | 9 | EV charger — optional, only if commissioned | +| `energy.ebus.device.pcs` | 15 | Power Control System — optional | +| `energy.ebus.device.power-flows` | 4 | Aggregated power flows (W) | diff --git a/src/span_panel_api/reference_payloads/__init__.py b/src/span_panel_api/reference_payloads/__init__.py new file mode 100644 index 0000000..a8596f9 --- /dev/null +++ b/src/span_panel_api/reference_payloads/__init__.py @@ -0,0 +1,66 @@ +"""Reference wire payloads, shipped as package data. + +These are captures of what a panel actually serves, published as part of the +distribution rather than kept in `tests/` — because the consumers that need +them most are *other* repositories. The Home Assistant integration checks the +field paths it declares against what the adapters can actually produce, and it +can only do that against a real schema document. Vendoring a copy of one is the +obvious move and the wrong one: a copy has no version, so it goes stale in +silence and the check starts verifying declarations against a schema no panel +runs. + +Shipping the payload here gives it the version of the release it came with. A +consumer that pins `span-panel-api==X` reads the document that release was +written against, by construction, with no copy to keep in sync. + +`homie_schema.json` belongs to this distribution and not to an adapter one: it +is the response of `span_panel_api.auth.get_homie_schema()`, modelled by +`V2HomieSchema` here, and dispatch reads its `data_model_version` to decide +*which* adapter parses the panel at all. The parent/child device tree is the +other half of that story and lives with the parser that can interpret it, in +`span_panel_api_schema_1.reference_payloads`. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from importlib import resources +import json + +from span_panel_api.models import HomieSchemaTypes + +_PACKAGE = "span_panel_api.reference_payloads" +_HOMIE_SCHEMA = "homie_schema.json" + + +def _load_object(name: str) -> Mapping[str, object]: + """Read one shipped payload and require it to be a JSON object.""" + text = resources.files(_PACKAGE).joinpath(name).read_text(encoding="utf-8") + document: object = json.loads(text) + if not isinstance(document, dict): + raise TypeError(f"{name} is not a JSON object") + return document + + +def homie_schema() -> Mapping[str, object]: + """The captured `GET /api/v2/homie/schema` response, parsed. + + Taken from a live 32-space panel on `spanos2/r202603/05`; serial numbers are + masked. `typesSchemaHash` is `sha256:d347556a07d98f40`, which is the value + `span_panel_api_schema_0.const.SCHEMA_ANCHOR` is pinned to. + """ + return _load_object(_HOMIE_SCHEMA) + + +def homie_schema_types() -> HomieSchemaTypes: + """The captured schema's `types` map. + + Separate from `homie_schema()` because this is the shape a field-metadata + build takes — `span_panel_api_schema_0.field_metadata.build_field_metadata` + accepts exactly this type — so a caller checking an adapter's output against + the schema never has to reach into an untyped document to get it. + """ + types = homie_schema()["types"] + if not isinstance(types, dict): + raise TypeError(f"{_HOMIE_SCHEMA} has no `types` object") + return types diff --git a/tests/fixtures/v2/homie_schema.json b/src/span_panel_api/reference_payloads/homie_schema.json similarity index 100% rename from tests/fixtures/v2/homie_schema.json rename to src/span_panel_api/reference_payloads/homie_schema.json diff --git a/tests/fixtures/v2/README.md b/tests/fixtures/v2/README.md index 04f9348..bfeabd1 100644 --- a/tests/fixtures/v2/README.md +++ b/tests/fixtures/v2/README.md @@ -4,24 +4,11 @@ Captured from a live SPAN Panel running firmware `spanos2/r202603/05`. Serial nu ## Files -| File | Source | Notes | -| ------------------- | -------------------------- | --------------------------------------------------------------------------------------- | -| `homie_schema.json` | `GET /api/v2/homie/schema` | Complete Homie property schema. Unauthenticated. Schema hash: `sha256:d347556a07d98f40` | -| `status.json` | `GET /api/v2/status` | v2 status probe response. Serial masked. | +| File | Source | Notes | +| ------------- | -------------------- | ---------------------------------------- | +| `status.json` | `GET /api/v2/status` | v2 status probe response. Serial masked. | -## Schema Hash +## Moved -`sha256:d347556a07d98f40` — use this to detect schema changes across firmware versions (compare against `typesSchemaHash` in live responses). - -## Node Types Present - -| Node Type | Properties | Notes | -| ------------------------------------------------ | ---------- | -------------------------------------------------- | -| `energy.ebus.device.distribution-enclosure.core` | 17 | Panel-wide state, network, hardware | -| `energy.ebus.device.lugs` | 7 | Upstream (main meter) and downstream (feedthrough) | -| `energy.ebus.device.circuit` | 16 | Per-circuit — one node per commissioned circuit | -| `energy.ebus.device.bess` | 12 | Battery — optional, only if commissioned | -| `energy.ebus.device.pv` | 7 | Solar — optional, only if commissioned | -| `energy.ebus.device.evse` | 9 | EV charger — optional, only if commissioned | -| `energy.ebus.device.pcs` | 15 | Power Control System — optional | -| `energy.ebus.device.power-flows` | 4 | Aggregated power flows (W) | +`homie_schema.json` is no longer a test fixture. It ships as package data at `src/span_panel_api/reference_payloads/homie_schema.json` and is read through `span_panel_api.reference_payloads.homie_schema()` — by this suite and by consumers alike, so there +is no copy anywhere that can go stale. Its provenance, schema hash and node-type table live in the README next to it. diff --git a/tests/test_detection_auth.py b/tests/test_detection_auth.py index 0aef2a5..91c5e8d 100644 --- a/tests/test_detection_auth.py +++ b/tests/test_detection_auth.py @@ -524,15 +524,12 @@ def test_panel_size_bad_format_raises(self): def test_panel_size_from_live_fixture(self): """panel_size works with the real panel schema fixture.""" - import json - from pathlib import Path + from span_panel_api.reference_payloads import homie_schema, homie_schema_types - fixture = Path(__file__).parent / "fixtures" / "v2" / "homie_schema.json" - data = json.loads(fixture.read_text()) schema = V2HomieSchema( - firmware_version=data["firmwareVersion"], + firmware_version=homie_schema()["firmwareVersion"], types_schema_hash="sha256:test", - types=data["types"], + types=homie_schema_types(), ) assert schema.panel_size == 32 diff --git a/tests/test_schema_one_adapter.py b/tests/test_schema_one_adapter.py index 24516cd..ef11a8b 100644 --- a/tests/test_schema_one_adapter.py +++ b/tests/test_schema_one_adapter.py @@ -8,7 +8,6 @@ from __future__ import annotations import json -from pathlib import Path import pytest @@ -16,8 +15,9 @@ from span_panel_api.models import V2HomieSchema from span_panel_api.protocol import SchemaAdapter from span_panel_api_schema_1 import SchemaOneAdapter +from span_panel_api_schema_1.reference_payloads import parent_child_tree -_TREE = json.loads((Path(__file__).parent / "fixtures" / "parent_child_tree.json").read_text(encoding="utf-8")) +_TREE = parent_child_tree() PANEL = "example-40t-001" SOLAR_CIRCUIT = "573066aaddd7b75114c4563ce3af18c4" diff --git a/tests/test_schema_one_against_simulator.py b/tests/test_schema_one_against_simulator.py index 0673240..000c429 100644 --- a/tests/test_schema_one_against_simulator.py +++ b/tests/test_schema_one_against_simulator.py @@ -1,7 +1,7 @@ """Drive the parser end to end from what the simulator actually publishes. -Every other schema_1 test runs on `fixtures/parent_child_tree.json`, which was -captured off the upstream *generic* eBus panel simulator. That fixture is fine +Every other schema_1 test runs on the tree this distribution ships as package +data, which was captured off the upstream *generic* eBus panel simulator. That fixture is fine for exercising the mapper, but it is not SPAN: it has never carried the extensions and divergences that are SPAN's own vocabulary, which is precisely the part a generic panel cannot produce. diff --git a/tests/test_schema_one_circuits.py b/tests/test_schema_one_circuits.py index 61034a2..259f6c6 100644 --- a/tests/test_schema_one_circuits.py +++ b/tests/test_schema_one_circuits.py @@ -1,22 +1,22 @@ """Mapping a v1.0 circuit device onto SpanCircuitSnapshot. -Driven from `fixtures/parent_child_tree.json`, captured off a real -`panel_sim` parent/child tree rather than hand-written, so the shapes are the -firmware's rather than my idea of them. +Driven from the tree this distribution ships as package data, captured off a +real `panel_sim` parent/child tree rather than hand-written, so the shapes are +the firmware's rather than my idea of them. """ from __future__ import annotations import json -from pathlib import Path import pytest from ebus_sdk.homie import DiscoveredDevice +from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree from span_panel_api_schema_1.circuits import build_circuit -_TREE = json.loads((Path(__file__).parent / "fixtures" / "parent_child_tree.json").read_text(encoding="utf-8")) +_TREE = parent_child_tree() # From the fixture: a 1-pole load, and a 2-pole backfeeding PV breaker. KITCHEN_LIGHTS = "0ab966b95f92a6a51ec548485aa85f54" @@ -24,18 +24,7 @@ def _device(device_id: str) -> DiscoveredDevice: - """Rebuild a DiscoveredDevice from the captured retained topics.""" - topics = _TREE[device_id] - device = DiscoveredDevice(device_id, "ebus") - device.update_description(topics["$description"]) - device.update_state(topics["$state"]) - for topic, value in topics.items(): - if topic.startswith("$"): - continue - node, _, prop = topic.partition("/") - if prop: - device.update_property(node, prop, value) - return device + return device_from_topics(device_id, _TREE[device_id]) @pytest.fixture(name="kitchen") diff --git a/tests/test_schema_one_devices.py b/tests/test_schema_one_devices.py index e415dc0..f55e6ac 100644 --- a/tests/test_schema_one_devices.py +++ b/tests/test_schema_one_devices.py @@ -2,13 +2,11 @@ from __future__ import annotations -import json -from pathlib import Path - import pytest from ebus_sdk.homie import DiscoveredDevice +from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree from span_panel_api_schema_1.devices import ( build_mid, build_battery, @@ -18,23 +16,13 @@ feed_circuit_ids, ) -_TREE = json.loads((Path(__file__).parent / "fixtures" / "parent_child_tree.json").read_text(encoding="utf-8")) +_TREE = parent_child_tree() SOLAR_CIRCUIT = "573066aaddd7b75114c4563ce3af18c4" def _device(device_id: str) -> DiscoveredDevice: - topics = _TREE[device_id] - device = DiscoveredDevice(device_id, "ebus") - device.update_description(topics["$description"]) - device.update_state(topics["$state"]) - for topic, value in topics.items(): - if topic.startswith("$"): - continue - node, _, prop = topic.partition("/") - if prop: - device.update_property(node, prop, value) - return device + return device_from_topics(device_id, _TREE[device_id]) def _circuits() -> list[DiscoveredDevice]: diff --git a/tests/test_schema_one_panel.py b/tests/test_schema_one_panel.py index 37ad586..f23e84a 100644 --- a/tests/test_schema_one_panel.py +++ b/tests/test_schema_one_panel.py @@ -1,19 +1,19 @@ """Panel-level mapping from the v1.0 tree. -Driven from `fixtures/parent_child_tree.json`, captured off a real `panel_sim` -parent/child tree. +Driven from the tree this distribution ships as package data, captured off a +real `panel_sim` parent/child tree. """ from __future__ import annotations import json -from pathlib import Path import pytest from ebus_sdk.homie import DiscoveredDevice from span_panel_api_schema_1.const import NODE_GRID, TYPE_BESS, TYPE_PV +from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree from span_panel_api_schema_1.panel import ( PanelFields, build_unmapped_tabs, @@ -27,24 +27,14 @@ resolve_run_config, ) -_TREE = json.loads((Path(__file__).parent / "fixtures" / "parent_child_tree.json").read_text(encoding="utf-8")) +_TREE = parent_child_tree() PANEL = "example-40t-001" MID = "bess-mid" def _device(device_id: str) -> DiscoveredDevice: - topics = _TREE[device_id] - device = DiscoveredDevice(device_id, "ebus") - device.update_description(topics["$description"]) - device.update_state(topics["$state"]) - for topic, value in topics.items(): - if topic.startswith("$"): - continue - node, _, prop = topic.partition("/") - if prop: - device.update_property(node, prop, value) - return device + return device_from_topics(device_id, _TREE[device_id]) @pytest.fixture(name="fields") diff --git a/tests/test_schema_one_snapshot.py b/tests/test_schema_one_snapshot.py index 197beee..e4c0a44 100644 --- a/tests/test_schema_one_snapshot.py +++ b/tests/test_schema_one_snapshot.py @@ -2,34 +2,22 @@ from __future__ import annotations -import json -from pathlib import Path - import pytest from ebus_sdk.homie import DiscoveredDevice from span_panel_api.models import SpanPanelSnapshot +from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree from span_panel_api_schema_1.snapshot import TreeRoles, build_snapshot -_TREE = json.loads((Path(__file__).parent / "fixtures" / "parent_child_tree.json").read_text(encoding="utf-8")) +_TREE = parent_child_tree() PANEL = "example-40t-001" SOLAR_CIRCUIT = "573066aaddd7b75114c4563ce3af18c4" def _device(device_id: str) -> DiscoveredDevice: - topics = _TREE[device_id] - device = DiscoveredDevice(device_id, "ebus") - device.update_description(topics["$description"]) - device.update_state(topics["$state"]) - for topic, value in topics.items(): - if topic.startswith("$"): - continue - node, _, prop = topic.partition("/") - if prop: - device.update_property(node, prop, value) - return device + return device_from_topics(device_id, _TREE[device_id]) def _children() -> list[DiscoveredDevice]: diff --git a/tests/test_schema_provenance.py b/tests/test_schema_provenance.py index f628836..556a809 100644 --- a/tests/test_schema_provenance.py +++ b/tests/test_schema_provenance.py @@ -18,24 +18,24 @@ from __future__ import annotations -import json -from pathlib import Path from typing import Any import pytest +from span_panel_api.reference_payloads import homie_schema from span_panel_api_schema_0 import const from span_panel_api_schema_0.field_metadata import _LUGS_FALLBACK, _PROPERTY_FIELD_MAP, _lookup_property -_FIXTURE = Path(__file__).parent / "fixtures" / "v2" / "homie_schema.json" - @pytest.fixture(name="schema") def _schema() -> dict[str, Any]: - """The captured `GET /api/v2/homie/schema` response — our stand-in for the panel.""" - with _FIXTURE.open() as handle: - loaded: dict[str, Any] = json.load(handle) - return loaded + """The captured `GET /api/v2/homie/schema` response — our stand-in for the panel. + + Read through the shipped accessor, so the anchor below is checked against + the bytes a consumer installing this release gets rather than against a + file that only exists in this checkout. + """ + return dict(homie_schema()) # --------------------------------------------------------------------------- diff --git a/uv.lock b/uv.lock index 6272d49..35c942a 100644 --- a/uv.lock +++ b/uv.lock @@ -1323,7 +1323,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "3.0.0b4" +version = "3.0.0b5" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -1391,7 +1391,7 @@ requires-dist = [{ name = "span-panel-api", editable = "." }] [[package]] name = "span-panel-api-schema-1" -version = "0.1.0b4" +version = "0.1.0b5" source = { editable = "packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From fdf4f93f42f40aaa1a2ffe0a333cec394b07e463 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:33:01 -0700 Subject: [PATCH 080/115] feat(schema-1): read the enclosure's shed-forecast into the snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The enclosure has published `energy.ebus.capability.shed-forecast` 0.1 since r202633 and nothing read it. The backup-planning numbers a user would actually alarm on — how long before the battery starts shedding circuits, how long before it is exhausted off-grid — were on the wire and stopped at the transport. Five new `SpanPanelSnapshot` fields carry them. All four times are `integer` minutes as the capability declares, read through a new `panel.integer` helper rather than through `number` plus a cast at each call site: a truncating conversion written five times is one that eventually gets written wrong, and parsing via float first means a publisher that serialises 3037 as "3037.0" is still publishing minutes. Every field is `None` when the panel publishes no such node, and `None` is load-bearing here rather than merely tidy. Zero minutes is a legitimate reading — shedding starts now — so a defaulted zero would be indistinguishable from the worst forecast the capability can report, and a consumer could not gate entity creation on presence. `_PROPERTY_FIELD_MAP` gains rows for the two live estimates only. Those buy the unit and datatype from the device's own `$description`, and with them the three-way resolution contract: a panel publishing the node while omitting one of the two reports degradation rather than absent hardware. The `full-charge-*` pair and `confidence` get no row on purpose — a consumer renders them beside the live estimates rather than as readings of their own, so a unit row would advertise a surface that is not there. The catalog is vendored and pinned, because the conformance suite requires one for every capability node the adapter addresses; a node read without a catalog would be unchecked while looking checked. Tests are proof by mutation throughout: each expected value is read out of the capture rather than written as a literal, and each has a paired test that republishes something different, deletes the property, or drops the whole node. Hardcoding the captured 3037 in the reader fails five of them. --- CHANGELOG.md | 15 + .../schema-1/spec/catalogs/shed-forecast.json | 41 +++ .../src/span_panel_api_schema_1/const.py | 16 + .../span_panel_api_schema_1/field_metadata.py | 9 + .../src/span_panel_api_schema_1/panel.py | 38 +++ .../src/span_panel_api_schema_1/snapshot.py | 5 + .../span_panel_api_schema_1/spec_lock.json | 3 +- src/span_panel_api/models.py | 29 ++ tests/test_schema_one_shed_forecast.py | 289 ++++++++++++++++++ 9 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 packages/schema-1/spec/catalogs/shed-forecast.json create mode 100644 tests/test_schema_one_shed_forecast.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 73e6467..a584dcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`shed-forecast` reaches the snapshot: five new `SpanPanelSnapshot` fields.** `shed_time_to_priority_shed_min`, `shed_total_time_remaining_min`, `shed_full_charge_time_to_priority_shed_min`, `shed_full_charge_total_time_remaining_min` and + `shed_forecast_confidence`. The enclosure has published `energy.ebus.capability.shed-forecast` 0.1 since r202633 and nothing read it — the backup-planning numbers ("how long before my battery starts shedding circuits", "how long before it is exhausted") + were on the wire and stopped at the transport. All four times are `integer` minutes as the capability declares, parsed through `panel.integer` so a publisher that serialises a whole number with a decimal point still resolves; `confidence` stays the raw + `LOW`/`MEDIUM`/`HIGH` string, because it qualifies the four times rather than standing alone. Every field is `None` when the panel publishes no such node, and `None` is load-bearing: zero minutes is a legitimate reading — shedding starts now — so a + defaulted zero would be indistinguishable from the worst forecast the capability can report. Purely additive; a panel that publishes nothing here is unchanged. +- **`_PROPERTY_FIELD_MAP` rows for the two live estimates**, `panel.shed_time_to_priority_shed_min` and `panel.shed_total_time_remaining_min`. That buys them the unit and datatype the device's own `$description` declares, and with it the three-way + resolution contract: a panel that publishes the node while omitting one of the two reports degradation rather than absent hardware. The `full-charge-*` pair and `confidence` deliberately get no row — a consumer renders them beside the two live estimates + rather than as readings of their own, so there is no unit surface for a row to describe. +- **`shed-forecast` 0.1 vendored under `packages/schema-1/spec/catalogs/`** and pinned in `spec_lock.json`, byte-copied from the specification at the recorded `synced_commit`. The conformance suite requires a catalog for every capability node the adapter + addresses, so a node read without one would be unchecked while looking checked. + ## [3.0.0b5] - 08/2026 Pre-release. Publishes the captured schema document consumers were copying by hand. diff --git a/packages/schema-1/spec/catalogs/shed-forecast.json b/packages/schema-1/spec/catalogs/shed-forecast.json new file mode 100644 index 0000000..50bda42 --- /dev/null +++ b/packages/schema-1/spec/catalogs/shed-forecast.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.shed-forecast", + "version": "0.1", + "status": "DRAFT", + "date": "2026-07-11", + "properties": { + "total-time-remaining": { + "datatype": "integer", + "unit": "min", + "req": "SHOULD", + "description": "At current state of energy, total time before all backed-up loads go unpowered." + }, + "time-to-priority-shed": { + "datatype": "integer", + "unit": "min", + "req": "SHOULD", + "description": "At current state of energy, time until priority-shed (e.g. `SOC_THRESHOLD`) circuits are auto-shed." + }, + "full-charge-total-time-remaining": { + "datatype": "integer", + "unit": "min", + "req": "SHOULD", + "description": "At 100% state of energy, total backup-duration capability." + }, + "full-charge-time-to-priority-shed": { + "datatype": "integer", + "unit": "min", + "req": "SHOULD", + "description": "At 100% state of energy, capability time until the priority-shed event." + }, + "confidence": { + "datatype": "enum", + "format": "LOW,MEDIUM,HIGH", + "req": "SHOULD", + "description": "The algorithm's self-assessed confidence: `LOW`, `MEDIUM`, `HIGH`. Reflects accumulated usage history." + } + } +} diff --git a/packages/schema-1/src/span_panel_api_schema_1/const.py b/packages/schema-1/src/span_panel_api_schema_1/const.py index 6b0ce66..1b4fdb4 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/const.py +++ b/packages/schema-1/src/span_panel_api_schema_1/const.py @@ -33,6 +33,12 @@ NODE_PCS = "pcs" NODE_POWER_FLOWS = "power-flows" NODE_SHED = "shed" +# `energy.ebus.capability.shed-forecast` 0.1 -- the enclosure's backup-planning +# estimates. A separate node from `shed`, which carries the policy and the +# asserted islanding state: `shed` says what the panel will do, `shed-forecast` +# says when. Present only where the enclosure publishes it, so every consumer of +# these fields gates on the node rather than defaulting. +NODE_SHED_FORECAST = "shed-forecast" NODE_GRID_FORMING = "grid-forming" NODE_SOC = "soc" NODE_STATUS = "status" @@ -55,6 +61,16 @@ # shed node PROP_ASSERTED_ISLANDING_STATE = "asserted-islanding-state" + +# shed-forecast node. All four times are `integer` minutes; `confidence` is the +# enum LOW/MEDIUM/HIGH qualifying them. The `full-charge-*` pair answers the +# hypothetical "if the BESS were full", so it is a capability figure rather than +# a live countdown and moves only when the installation does. +PROP_TIME_TO_PRIORITY_SHED = "time-to-priority-shed" +PROP_TOTAL_TIME_REMAINING = "total-time-remaining" +PROP_FULL_CHARGE_TIME_TO_PRIORITY_SHED = "full-charge-time-to-priority-shed" +PROP_FULL_CHARGE_TOTAL_TIME_REMAINING = "full-charge-total-time-remaining" +PROP_CONFIDENCE = "confidence" # `energy.ebus.capability.grid-forming` 0.1: "Static hardware capability: does this # inverter support grid-forming operation at all?" -- the same *kind* of statement # flat's `grid-islandable` made, and a MUST on the capability. diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index c72d327..b639243 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -26,6 +26,7 @@ NODE_LOAD_SHED, NODE_METER, NODE_POWER_FLOWS, + NODE_SHED_FORECAST, NODE_SOC, NODE_STATUS, NODE_SWITCH, @@ -67,6 +68,14 @@ (TYPE_PANEL, NODE_POWER_FLOWS, "battery", "panel.power_flow_battery"), (TYPE_PANEL, NODE_POWER_FLOWS, "grid", "panel.power_flow_grid"), (TYPE_PANEL, NODE_POWER_FLOWS, "site", "panel.power_flow_site"), + # Only the two live estimates. The `full-charge-*` pair and `confidence` + # are read too, but a consumer renders them beside these rather than as + # readings of their own, so a unit row for them would advertise a surface + # that is not there. The pair below is what carries `min`, and with it the + # declared-gap signal: a panel that publishes the node while omitting one of + # these reports it as degradation rather than as absent hardware. + (TYPE_PANEL, NODE_SHED_FORECAST, "time-to-priority-shed", "panel.shed_time_to_priority_shed_min"), + (TYPE_PANEL, NODE_SHED_FORECAST, "total-time-remaining", "panel.shed_total_time_remaining_min"), # --- Lugs → panel.* ------------------------------------------------------ # Deliberately absent. Which device a lugs property belongs to comes from # `info/direction` at read time, and a table keyed on (type, node, property) diff --git a/packages/schema-1/src/span_panel_api_schema_1/panel.py b/packages/schema-1/src/span_panel_api_schema_1/panel.py index ad097aa..3e1cdc6 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/panel.py +++ b/packages/schema-1/src/span_panel_api_schema_1/panel.py @@ -36,15 +36,19 @@ NODE_METER, NODE_POWER_FLOWS, NODE_SHED, + NODE_SHED_FORECAST, NODE_STATUS, PANEL_SIZE_BY_MODEL, PROP_ACTIVE_POWER, PROP_ASSERTED_ISLANDING_STATE, PROP_CAPABLE, PROP_CLOUD_CONNECTION, + PROP_CONFIDENCE, PROP_ETHERNET, PROP_EXPORTED_ENERGY, PROP_FIRMWARE_VERSION, + PROP_FULL_CHARGE_TIME_TO_PRIORITY_SHED, + PROP_FULL_CHARGE_TOTAL_TIME_REMAINING, PROP_GRID_FORMING_ENTITY, PROP_IMPORTED_ENERGY, PROP_MODEL, @@ -52,6 +56,8 @@ PROP_RELAY, PROP_SERIAL_NUMBER, PROP_STATE, + PROP_TIME_TO_PRIORITY_SHED, + PROP_TOTAL_TIME_REMAINING, PROP_VOLTAGE_A, PROP_VOLTAGE_B, PROP_WIFI, @@ -103,6 +109,25 @@ def number(device: DiscoveredDevice | None, node: str, prop: str) -> float | Non return None +def integer(device: DiscoveredDevice | None, node: str, prop: str) -> int | None: + """A property the tree declares as `integer`, or `None` when it is not published. + + Separate from `number` rather than casting its result at the call site, + because the two answer different questions. `number` exists for `float` + properties and returns `float`; a caller that wanted an `int` would have to + remember that `int(float(...))` truncates, and a truncating conversion + written once per call site is one that eventually gets written wrong. + + Parsed through `float` first so a publisher that sends `3037.0` for an + integer property still resolves — the datatype is a declaration about the + quantity, and rejecting a decimal point would turn a formatting choice into + a missing entity. A value that is not a number at all yields `None`, which + is the same answer as not publishing: neither is a reading. + """ + raw = number(device, node, prop) + return None if raw is None else int(raw) + + def flag(device: DiscoveredDevice | None, node: str, prop: str) -> bool: return text(device, node, prop).strip().lower() == "true" @@ -315,6 +340,19 @@ def __init__( # Not published by v1.0 firmware. self.wifi_ssid: str | None = None + # Backup-planning forecast. Every field stays `None` when the panel + # publishes no `shed-forecast` node, which is what lets a consumer gate + # entity creation on presence instead of showing a fabricated zero. + self.shed_time_to_priority_shed_min = integer(panel, NODE_SHED_FORECAST, PROP_TIME_TO_PRIORITY_SHED) + self.shed_total_time_remaining_min = integer(panel, NODE_SHED_FORECAST, PROP_TOTAL_TIME_REMAINING) + self.shed_full_charge_time_to_priority_shed_min = integer( + panel, NODE_SHED_FORECAST, PROP_FULL_CHARGE_TIME_TO_PRIORITY_SHED + ) + self.shed_full_charge_total_time_remaining_min = integer( + panel, NODE_SHED_FORECAST, PROP_FULL_CHARGE_TOTAL_TIME_REMAINING + ) + self.shed_forecast_confidence = text(panel, NODE_SHED_FORECAST, PROP_CONFIDENCE) or None + # Matches `schema_0`'s epsilon so the no-MID heuristic answers identically on the two # adapters — the tier exists precisely for panels where nothing authoritative is diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index 007ec63..bb11481 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -172,6 +172,11 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re power_flow_battery=fields.power_flow_battery, power_flow_grid=fields.power_flow_grid, power_flow_site=fields.power_flow_site, + shed_time_to_priority_shed_min=fields.shed_time_to_priority_shed_min, + shed_total_time_remaining_min=fields.shed_total_time_remaining_min, + shed_full_charge_time_to_priority_shed_min=fields.shed_full_charge_time_to_priority_shed_min, + shed_full_charge_total_time_remaining_min=fields.shed_full_charge_total_time_remaining_min, + shed_forecast_confidence=fields.shed_forecast_confidence, upstream_l1_current_a=fields.upstream_l1_current_a, upstream_l2_current_a=fields.upstream_l2_current_a, downstream_l1_current_a=fields.downstream_l1_current_a, diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json index 32cfb89..e1a4ee0 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -35,6 +35,7 @@ "pcs": "0.3", "power-flows": "0.1", "shed": "0.2", + "shed-forecast": "0.1", "soc": "0.1", "status": "0.1", "switch": "0.1" @@ -49,5 +50,5 @@ "device-types": "0.5" } }, - "notes": "role=consumer: span-panel-api-schema-1 parses the Homie 5 distribution-enclosure tree that SPAN firmware r202633+ publishes, and is hot-loaded by span-panel-api through the span_panel_api.schema_adapters entry-point group. It is the consumer counterpart to SpanPanel/panelbench (role=publisher), which is pinned to the same synced_commit; the shared anchor between them is the firmware range above, not this commit, because the spec says what a device class MAY publish while a panel publishes one specific tree. PROVENANCE: packages/schema-1/spec/catalogs/*.json are byte copies of the specification's capabilities/ at synced_commit, and spec/registries/device-types.md is a byte copy of that registry. They are verified by byte comparison when a specification checkout is available (EBUS_SPEC_DIR); the comparison skips when none is, so the conformance check below always runs while the provenance check is opportunistic. Never hand-edit anything under spec/ -- an edit makes the byte comparison meaningless. WHAT IS VENDORED AND WHY SO LITTLE: only the 13 capability catalogs this adapter addresses, because a consumer needs the vocabulary it reads and nothing else. Datatypes, units and formats are deliberately NOT taken from these catalogs at runtime: the adapter reads them from each device's $description, because the same capability exposes different properties on different device classes (meter is voltage on the panel, power and energy on a circuit, both currents on lugs) and the catalog is the superset across all hardware rather than a statement about this panel. The vendored copies exist to be checked against, not to be parsed in production. ABSTRACT UNITS: four catalog properties carry unit: energy, a dimension rather than a unit (conventions/property-json.md 0.2). Being description-driven makes this adapter correct here by construction, and a test asserts it rather than leaving it to luck. EXTENSIONS: SPAN publishes properties no catalog defines -- per-phase meter readings, panel status links, circuit spaces. Those are legal under the specification and are enumerated as an explicit allowlist in tests/test_schema_one_conformance.py, so a name that is absent from the catalog has to be declared deliberately rather than assumed. PINNING RULE: pin what this adapter actually reads AND that exists in the current spec. pv/evse/mid/lugs have no standalone versioned device model upstream and are covered transitively as child device_types of distribution-enclosure 0.12, so they are not separately pinned." + "notes": "role=consumer: span-panel-api-schema-1 parses the Homie 5 distribution-enclosure tree that SPAN firmware r202633+ publishes, and is hot-loaded by span-panel-api through the span_panel_api.schema_adapters entry-point group. It is the consumer counterpart to SpanPanel/panelbench (role=publisher), which is pinned to the same synced_commit; the shared anchor between them is the firmware range above, not this commit, because the spec says what a device class MAY publish while a panel publishes one specific tree. PROVENANCE: packages/schema-1/spec/catalogs/*.json are byte copies of the specification's capabilities/ at synced_commit, and spec/registries/device-types.md is a byte copy of that registry. They are verified by byte comparison when a specification checkout is available (EBUS_SPEC_DIR); the comparison skips when none is, so the conformance check below always runs while the provenance check is opportunistic. Never hand-edit anything under spec/ -- an edit makes the byte comparison meaningless. WHAT IS VENDORED AND WHY SO LITTLE: only the 15 capability catalogs this adapter addresses, because a consumer needs the vocabulary it reads and nothing else. Datatypes, units and formats are deliberately NOT taken from these catalogs at runtime: the adapter reads them from each device's $description, because the same capability exposes different properties on different device classes (meter is voltage on the panel, power and energy on a circuit, both currents on lugs) and the catalog is the superset across all hardware rather than a statement about this panel. The vendored copies exist to be checked against, not to be parsed in production. ABSTRACT UNITS: four catalog properties carry unit: energy, a dimension rather than a unit (conventions/property-json.md 0.2). Being description-driven makes this adapter correct here by construction, and a test asserts it rather than leaving it to luck. EXTENSIONS: SPAN publishes properties no catalog defines -- per-phase meter readings, panel status links, circuit spaces. Those are legal under the specification and are enumerated as an explicit allowlist in tests/test_schema_one_conformance.py, so a name that is absent from the catalog has to be declared deliberately rather than assumed. PINNING RULE: pin what this adapter actually reads AND that exists in the current spec. pv/evse/mid/lugs have no standalone versioned device model upstream and are covered transitively as child device_types of distribution-enclosure 0.12, so they are not separately pinned." } diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 0a87682..93d1c57 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -319,6 +319,35 @@ class SpanPanelSnapshot: power_flow_grid: float | None = None # v2: power-flows/grid (W) power_flow_site: float | None = None # v2: power-flows/site (W) + # Backup-planning forecast (`shed-forecast`, v1.0 only; None when the + # enclosure publishes no such node). Minutes, as the capability declares — + # `int` rather than `float` because the wire datatype is `integer` and a + # forecast is not measured to a fraction of a minute. + # + # `None` is load-bearing on all five: a panel that does not publish the node + # must produce no entity, and zero is a legitimate reading ("shedding + # starts now"). Defaulting any of these to 0 would say exactly that. + shed_time_to_priority_shed_min: int | None = None + """`shed-forecast/time-to-priority-shed` — minutes until the next priority tier sheds.""" + shed_total_time_remaining_min: int | None = None + """`shed-forecast/total-time-remaining` — minutes until every sheddable circuit is shed.""" + shed_full_charge_time_to_priority_shed_min: int | None = None + """`shed-forecast/full-charge-time-to-priority-shed` — the same estimate from a full BESS. + + A capability figure, not a countdown: it answers "what would this + installation give me if the battery were full", so it moves when the + hardware or the load profile changes rather than as the battery drains. + """ + shed_full_charge_total_time_remaining_min: int | None = None + """`shed-forecast/full-charge-total-time-remaining` — total runtime from a full BESS.""" + shed_forecast_confidence: str | None = None + """`shed-forecast/confidence` — LOW | MEDIUM | HIGH, the algorithm's self-assessment. + + Kept as the raw wire string. It qualifies the four times rather than + standing alone, and a consumer that shows it beside them needs the value the + catalog's enum defines, not a re-encoding of it. + """ + # Upstream lugs per-phase current (None when not available) upstream_l1_current_a: float | None = None # v2: upstream-lugs/l1-current (A) upstream_l2_current_a: float | None = None # v2: upstream-lugs/l2-current (A) diff --git a/tests/test_schema_one_shed_forecast.py b/tests/test_schema_one_shed_forecast.py new file mode 100644 index 0000000..08798b2 --- /dev/null +++ b/tests/test_schema_one_shed_forecast.py @@ -0,0 +1,289 @@ +"""The enclosure's backup-planning forecast, from the wire to the snapshot. + +`shed-forecast` 0.1 publishes four `integer` minute estimates and a confidence +enum. Nothing derives them and nothing defaults them: every assertion here is +against a value the captured tree actually publishes, and every one of them has +a paired test that republishes something different, so a reading that the parser +hardcoded rather than read cannot pass. +""" + +from __future__ import annotations + +from collections.abc import Mapping +import json +from typing import Any + +import pytest + +from ebus_sdk.homie import DiscoveredDevice + +from span_panel_api.models import FieldMetadata, SpanPanelSnapshot +from span_panel_api_schema_1.field_metadata import build_field_metadata +from span_panel_api_schema_1.reference_payloads import ( + RetainedTopicTree, + device_from_topics, + parent_child_tree, +) +from span_panel_api_schema_1.snapshot import build_snapshot + +PANEL = "example-40t-001" +NODE = "shed-forecast" + +TIME_TO_PRIORITY_SHED = "time-to-priority-shed" +TOTAL_TIME_REMAINING = "total-time-remaining" +FULL_CHARGE_TIME_TO_PRIORITY_SHED = "full-charge-time-to-priority-shed" +FULL_CHARGE_TOTAL_TIME_REMAINING = "full-charge-total-time-remaining" +CONFIDENCE = "confidence" + +_LIVE_PATHS = { + TIME_TO_PRIORITY_SHED: "panel.shed_time_to_priority_shed_min", + TOTAL_TIME_REMAINING: "panel.shed_total_time_remaining_min", +} + + +def _mutable_tree() -> dict[str, dict[str, str]]: + """A deep-enough copy of the capture that a test can rewrite one topic. + + Rewriting the published value is the whole point of this module: an + assertion against a constant proves nothing unless the same code reports a + different constant when the panel sends one. + """ + return {device_id: dict(topics) for device_id, topics in parent_child_tree().items()} + + +def _snapshot(tree: RetainedTopicTree) -> SpanPanelSnapshot: + panel = device_from_topics(PANEL, tree[PANEL]) + children = [device_from_topics(device_id, topics) for device_id, topics in tree.items() if device_id != PANEL] + return build_snapshot(panel, children) + + +def _devices(tree: RetainedTopicTree) -> list[DiscoveredDevice]: + return [device_from_topics(device_id, topics) for device_id, topics in tree.items()] + + +def _published(property_id: str) -> str: + return parent_child_tree()[PANEL][f"{NODE}/{property_id}"] + + +def _without_property(tree: dict[str, dict[str, str]], property_id: str) -> dict[str, dict[str, str]]: + """Stop publishing one forecast property, and stop declaring it too. + + Both halves, because they are different situations to the metadata builder — + an undeclared property is a gap and an unpublished one is a missing value — + and this helper models a firmware that simply does not have the property. + """ + del tree[PANEL][f"{NODE}/{property_id}"] + description = json.loads(tree[PANEL]["$description"]) + del description["nodes"][NODE]["properties"][property_id] + tree[PANEL]["$description"] = json.dumps(description) + return tree + + +def _without_node(tree: dict[str, dict[str, str]]) -> dict[str, dict[str, str]]: + """A panel that publishes no `shed-forecast` node at all.""" + for topic in [topic for topic in tree[PANEL] if topic.startswith(f"{NODE}/")]: + del tree[PANEL][topic] + description = json.loads(tree[PANEL]["$description"]) + del description["nodes"][NODE] + tree[PANEL]["$description"] = json.dumps(description) + return tree + + +def _declared(tree: RetainedTopicTree) -> Mapping[str, Any]: + description: dict[str, Any] = json.loads(tree[PANEL]["$description"]) + nodes: dict[str, Any] = description["nodes"] + return nodes + + +# --------------------------------------------------------------------------- +# The capture publishes it; the snapshot reports what was published +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_the_whole_capability() -> None: + """Guard the premise. Every assertion below reads the tree for its expected + value, so a capture that stopped publishing the node would make them all + vacuously true rather than failing.""" + tree = parent_child_tree() + + assert NODE in _declared(tree) + for property_id in (*_LIVE_PATHS, FULL_CHARGE_TIME_TO_PRIORITY_SHED, FULL_CHARGE_TOTAL_TIME_REMAINING, CONFIDENCE): + assert f"{NODE}/{property_id}" in tree[PANEL] + + +def test_every_forecast_property_reaches_the_snapshot() -> None: + """Read against the tree rather than against literals: the expected value is + whatever the panel published, so changing the capture changes the + expectation instead of silently disagreeing with it.""" + snapshot = _snapshot(parent_child_tree()) + + assert snapshot.shed_time_to_priority_shed_min == int(_published(TIME_TO_PRIORITY_SHED)) + assert snapshot.shed_total_time_remaining_min == int(_published(TOTAL_TIME_REMAINING)) + assert snapshot.shed_full_charge_time_to_priority_shed_min == int(_published(FULL_CHARGE_TIME_TO_PRIORITY_SHED)) + assert snapshot.shed_full_charge_total_time_remaining_min == int(_published(FULL_CHARGE_TOTAL_TIME_REMAINING)) + assert snapshot.shed_forecast_confidence == _published(CONFIDENCE) + + +def test_the_two_live_estimates_are_not_the_same_reading() -> None: + """`time-to-priority-shed` and `total-time-remaining` are distinct in the + capture, so a parser that crossed the two would fail here rather than + reporting a plausible pair.""" + snapshot = _snapshot(parent_child_tree()) + + assert snapshot.shed_time_to_priority_shed_min != snapshot.shed_total_time_remaining_min + + +@pytest.mark.parametrize( + ("property_id", "attribute", "republished", "expected"), + [ + (TIME_TO_PRIORITY_SHED, "shed_time_to_priority_shed_min", "17", 17), + (TOTAL_TIME_REMAINING, "shed_total_time_remaining_min", "1440", 1440), + ( + FULL_CHARGE_TIME_TO_PRIORITY_SHED, + "shed_full_charge_time_to_priority_shed_min", + "615", + 615, + ), + ( + FULL_CHARGE_TOTAL_TIME_REMAINING, + "shed_full_charge_total_time_remaining_min", + "720", + 720, + ), + (CONFIDENCE, "shed_forecast_confidence", "LOW", "LOW"), + ], +) +def test_republishing_a_property_moves_the_field_that_reads_it( + property_id: str, attribute: str, republished: str, expected: int | str +) -> None: + """The mutation half. Each value differs from the captured one *and* from + every other captured one, so a field wired to the wrong property reports a + number the assertion rejects.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/{property_id}"] = republished + + assert getattr(_snapshot(tree), attribute) == expected + + +@pytest.mark.parametrize( + ("property_id", "attribute"), + [ + (TIME_TO_PRIORITY_SHED, "shed_time_to_priority_shed_min"), + (TOTAL_TIME_REMAINING, "shed_total_time_remaining_min"), + (FULL_CHARGE_TIME_TO_PRIORITY_SHED, "shed_full_charge_time_to_priority_shed_min"), + (FULL_CHARGE_TOTAL_TIME_REMAINING, "shed_full_charge_total_time_remaining_min"), + (CONFIDENCE, "shed_forecast_confidence"), + ], +) +def test_a_property_the_panel_does_not_publish_is_none(property_id: str, attribute: str) -> None: + """`None`, never zero. Zero minutes is a legitimate forecast — shedding + starts now — so a default would be indistinguishable from the worst reading + the capability can report.""" + snapshot = _snapshot(_without_property(_mutable_tree(), property_id)) + + assert getattr(snapshot, attribute) is None + + +def test_dropping_one_property_leaves_the_others_reading() -> None: + """Absence is per-property, so a panel with a partial forecast still reports + the part it has.""" + snapshot = _snapshot(_without_property(_mutable_tree(), TIME_TO_PRIORITY_SHED)) + + assert snapshot.shed_time_to_priority_shed_min is None + assert snapshot.shed_total_time_remaining_min == int(_published(TOTAL_TIME_REMAINING)) + + +def test_a_panel_with_no_forecast_node_carries_no_forecast() -> None: + """The presence gate a consumer builds entities from.""" + snapshot = _snapshot(_without_node(_mutable_tree())) + + assert snapshot.shed_time_to_priority_shed_min is None + assert snapshot.shed_total_time_remaining_min is None + assert snapshot.shed_full_charge_time_to_priority_shed_min is None + assert snapshot.shed_full_charge_total_time_remaining_min is None + assert snapshot.shed_forecast_confidence is None + + +def test_zero_minutes_is_a_reading_and_not_an_absence() -> None: + """The distinction the `None` default exists to keep: shedding has started.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/{TIME_TO_PRIORITY_SHED}"] = "0" + + assert _snapshot(tree).shed_time_to_priority_shed_min == 0 + + +def test_a_whole_number_sent_with_a_decimal_point_still_reads() -> None: + """The datatype declares the quantity, not the formatting. A publisher that + serialises 3037 as `3037.0` has not stopped publishing minutes.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/{TOTAL_TIME_REMAINING}"] = "4321.0" + + assert _snapshot(tree).shed_total_time_remaining_min == 4321 + + +def test_a_value_that_is_not_a_number_reads_as_absent() -> None: + """Same answer as not publishing, because neither is a reading.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/{TOTAL_TIME_REMAINING}"] = "unknown" + + assert _snapshot(tree).shed_total_time_remaining_min is None + + +# --------------------------------------------------------------------------- +# Metadata: the two live estimates carry the declared unit +# --------------------------------------------------------------------------- + + +def test_the_live_estimates_take_their_unit_from_the_tree() -> None: + metadata = build_field_metadata(_devices(parent_child_tree())) + declared = _declared(parent_child_tree())[NODE]["properties"] + + for property_id, field_path in _LIVE_PATHS.items(): + entry = metadata[field_path] + assert entry.resolved is True + assert entry.unit == declared[property_id]["unit"] + assert entry.datatype == declared[property_id]["datatype"] + + +def test_changing_the_declared_unit_changes_the_metadata() -> None: + """The mutation proof for the metadata half: the unit is read from the + device's `$description`, not from the vendored catalog and not from a + literal in the adapter.""" + tree = _mutable_tree() + description = json.loads(tree[PANEL]["$description"]) + description["nodes"][NODE]["properties"][TOTAL_TIME_REMAINING]["unit"] = "h" + tree[PANEL]["$description"] = json.dumps(description) + + metadata = build_field_metadata(_devices(tree)) + + assert metadata["panel.shed_total_time_remaining_min"].unit == "h" + + +def test_a_declared_node_missing_a_property_is_a_gap_not_absent_hardware() -> None: + """The three-way contract. The node is here, so an omitted property is + degradation and gets an unresolved row rather than no row.""" + metadata = build_field_metadata(_devices(_without_property(_mutable_tree(), TIME_TO_PRIORITY_SHED))) + + entry = metadata["panel.shed_time_to_priority_shed_min"] + assert entry == FieldMetadata(unit=None, datatype="unknown", resolved=False) + + +def test_no_forecast_node_produces_no_rows_at_all() -> None: + """Hardware that is not there is not a defect: no entry, so a consumer reads + "nothing will populate this" rather than "this is broken".""" + metadata = build_field_metadata(_devices(_without_node(_mutable_tree()))) + + for field_path in _LIVE_PATHS.values(): + assert field_path not in metadata + + +def test_the_hypothetical_pair_and_confidence_carry_no_metadata_row() -> None: + """Deliberate, and asserted so it stays deliberate. They are read into the + snapshot but rendered beside the two live estimates rather than as readings + of their own, so there is no unit surface for a row to describe. + """ + metadata = build_field_metadata(_devices(parent_child_tree())) + + assert "panel.shed_full_charge_time_to_priority_shed_min" not in metadata + assert "panel.shed_full_charge_total_time_remaining_min" not in metadata + assert "panel.shed_forecast_confidence" not in metadata From 3178623b0f8c7092ec61842e278f9d5bee1eee2d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:23:45 -0700 Subject: [PATCH 081/115] feat(schema-1): read the BESS's own meter and link health into the snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The battery device has published `meter/active-power` and `status/communication-state` all along and neither reached a field. A consumer could show the enclosure's arbitrated `power_flow_battery` and nothing the BESS itself reports about its own power or its own link. `SpanBatterySnapshot.power_w` is **charge-positive**, and the wire is not. The enclosure meters the BESS the way it meters a circuit it feeds, so a charging battery publishes a negative `meter/active-power`; `build_battery` negates it, exactly as `build_circuit` does for a load, and the snapshot's rule then holds on every power field: positive means power flowing into the metered device. The `-0.0` guard comes from `build_circuit` too — negating zero yields a reading that compares equal to zero and renders as "-0.0" beside it. The asymmetry with `panel.power_flow_battery` is deliberate and documented in both places. The capability catalog defines that property as discharge-positive and both adapters pass it through untouched, so the two fields describe the same physical power in opposite frames. `battery.power_w` is the one already in the snapshot's frame. A test asserts the two properties agree in sign *on the wire*, which is what makes "negate exactly one of them" a fact about the capture rather than a preference — and the charging premise is asserted separately, so a recapture with the battery discharging fails saying that, not reading as a mapper bug. `communication_state` stays the published enum string rather than collapsing to a bool: DEGRADED is neither OK nor LOST. It is not merged into `battery.connected`, which is the enclosure's `connection/fed-by-device-status` view of the same link — one is the device speaking about itself, the other the panel speaking about it. A test holds them independent by reporting LOST on a link the enclosure still calls OK. Both get `_PROPERTY_FIELD_MAP` rows, which buys the unit and datatype from the BESS's own `$description` plus the three-way resolution contract. A row describes the property; the sign flip the mapper applies is not a unit change. `test_der_additions_are_provisional_or_attested_but_never_unexamined` caught these as unexamined additions, correctly, and they fit neither existing bucket. `PROVISIONAL_DER` means "flat has this property and the frozen simulator does not send it", so a real capture could still reclassify it as identity; flat's BESS device class declares neither of these at all, so no capture ever will. Hence a third bucket, `NEW_IN_V1_0`, told apart from the second mechanically — by whether schema_0's field map holds a row — rather than by prose. Tests read every expected value out of the capture and prove it by mutation: republish, delete the property, drop the node. Dropping the negation fails two; hardcoding the captured values fails eight. --- CHANGELOG.md | 11 ++ .../src/span_panel_api_schema_1/const.py | 6 + .../src/span_panel_api_schema_1/devices.py | 35 +++- .../span_panel_api_schema_1/field_metadata.py | 7 + src/span_panel_api/models.py | 21 ++ tests/test_schema_migration_delta.py | 70 ++++++- tests/test_schema_one_adapter.py | 7 + tests/test_schema_one_devices.py | 180 ++++++++++++++++++ 8 files changed, 333 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a584dcd..b76f855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- **The BESS's own meter and link health reach the snapshot: `SpanBatterySnapshot.power_w` and `SpanBatterySnapshot.communication_state`.** The battery device has published `meter/active-power` and `status/communication-state` all along and neither reached + a field, so a consumer could show the enclosure's arbitrated `power_flow_battery` and nothing the BESS itself reports. Both are `None` on a BESS that publishes no such node, and on every flat panel — the flat schema's BESS device class declares neither + property, so this is new surface rather than a re-sourcing, and nothing that exists today changes. +- **`power_w` is charge-positive, and the wire is not.** The enclosure meters the BESS the way it meters a circuit it feeds, so a charging battery publishes a _negative_ `meter/active-power`; `build_battery` negates it, exactly as `build_circuit` does for + a load, so the snapshot's rule holds on every power field: positive means power flowing into the metered device. Note the deliberate asymmetry with `panel.power_flow_battery`, which the capability catalog defines as discharge-positive and which both + adapters pass through untouched. The two describe the same physical power in opposite frames; `battery.power_w` is the one already in the snapshot's frame, so a consumer rendering both negates the other. +- **`communication_state` stays the published enum string** (`OK`/`DEGRADED`/`LOST`/`UNKNOWN`) rather than collapsing to a bool: `DEGRADED` is neither `OK` nor `LOST`, and a bool would have to pick one. It is deliberately not merged into + `battery.connected`, which is the _enclosure's_ `connection/fed-by-device-status` view of the same link. One is the device speaking about itself and the other the panel speaking about it, and the migration guide warns against conflating them. +- **`_PROPERTY_FIELD_MAP` rows for both**, which buys them the unit and datatype the BESS's own `$description` declares plus the three-way resolution contract — a BESS that publishes the node while omitting the property reports degradation rather than + absent hardware. The row describes the property; the sign flip the mapper applies is not a unit change. + - **`shed-forecast` reaches the snapshot: five new `SpanPanelSnapshot` fields.** `shed_time_to_priority_shed_min`, `shed_total_time_remaining_min`, `shed_full_charge_time_to_priority_shed_min`, `shed_full_charge_total_time_remaining_min` and `shed_forecast_confidence`. The enclosure has published `energy.ebus.capability.shed-forecast` 0.1 since r202633 and nothing read it — the backup-planning numbers ("how long before my battery starts shedding circuits", "how long before it is exhausted") were on the wire and stopped at the transport. All four times are `integer` minutes as the capability declares, parsed through `panel.integer` so a publisher that serialises a whole number with a decimal point still resolves; `confidence` stays the raw diff --git a/packages/schema-1/src/span_panel_api_schema_1/const.py b/packages/schema-1/src/span_panel_api_schema_1/const.py index 1b4fdb4..8954bb7 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/const.py +++ b/packages/schema-1/src/span_panel_api_schema_1/const.py @@ -89,6 +89,12 @@ PROP_CLOUD_CONNECTION = "cloud-connection" PROP_ETHERNET = "ethernet" PROP_WIFI = "wifi" +# `energy.ebus.capability.status` 0.1: the publisher's view of its own link to +# the device it represents (proxy) or to its backhaul (native). Enum +# OK/DEGRADED/LOST/UNKNOWN. Orthogonal to whether the eBus publisher is reporting +# to *its* consumers, and orthogonal to the enclosure's `connection/*` view of +# the same device -- see `devices.py`'s module docstring. +PROP_COMMUNICATION_STATE = "communication-state" # -- Values ----------------------------------------------------------------- diff --git a/packages/schema-1/src/span_panel_api_schema_1/devices.py b/packages/schema-1/src/span_panel_api_schema_1/devices.py index 5cd1c5b..0146495 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/devices.py +++ b/packages/schema-1/src/span_panel_api_schema_1/devices.py @@ -15,7 +15,20 @@ panel-side owner's ``connection/*-device-status``, not anything the BESS publishes about itself. The BESS's own ``status/communication-state`` looks like the right property and is a different signal — the migration guide warns -against conflating them. +against conflating them. Both are now carried, in separate fields +(``connected`` and ``communication_state``), because they answer different +questions: the panel's view of the link, and the publisher's view of its own. + +**Battery power is charge-positive, and the wire is not.** The enclosure meters +the BESS the way it meters a circuit — a device it feeds — so a charging battery +publishes a *negative* ``meter/active-power``. ``build_circuit`` negates for +exactly this reason, and ``build_battery`` does the same, so the snapshot's rule +holds everywhere: positive means power flowing into the metered device. + +Note the enclosure's own ``power-flows/battery`` uses the opposite convention +(the capability catalog defines it as discharge-positive) and is passed through +untouched into ``panel.power_flow_battery``. Same physical power, opposite +frames; ``battery.power_w`` is the one already in the snapshot's frame. """ from __future__ import annotations @@ -31,6 +44,8 @@ NODE_SOC, NODE_STATUS, NODE_SWITCH, + PROP_ACTIVE_POWER, + PROP_COMMUNICATION_STATE, UNKNOWN, ) from span_panel_api_schema_1.panel import number, resolve_grid_forming_device_name, text @@ -96,6 +111,19 @@ def connection_status_for(device_id: str, owners: list[DiscoveredDevice]) -> str return None +def _charge_positive(raw_power_w: float | None) -> float | None: + """Flip the enclosure's meter frame to the snapshot's charge-positive one. + + `None` stays `None`: a BESS that publishes no `meter` node has no power + reading, which is not the same as zero. The `0.0` guard is `build_circuit`'s, + for the same reason — negating `0.0` yields `-0.0`, which compares equal to + `0.0` and formats as `"-0.0"`. + """ + if raw_power_w is None: + return None + return 0.0 if raw_power_w == 0.0 else -raw_power_w + + def build_battery(bess: DiscoveredDevice | None, owners: list[DiscoveredDevice]) -> SpanBatterySnapshot: """Build the battery snapshot. An uncommissioned panel yields the empty one.""" if bess is None: @@ -120,6 +148,11 @@ def build_battery(bess: DiscoveredDevice | None, owners: list[DiscoveredDevice]) nameplate_capacity_kwh=number(bess, NODE_INFO, PROP_NAMEPLATE_CAPACITY), # None when unclaimed, so "nobody has said" stays distinct from "not OK". connected=None if status is None else status == STATUS_OK, + power_w=_charge_positive(number(bess, NODE_METER, PROP_ACTIVE_POWER)), + # The BESS's own link health, kept as the published enum string rather + # than collapsed to a bool: DEGRADED is neither OK nor LOST, and a bool + # would have to pick one. + communication_state=_optional(text(bess, NODE_STATUS, PROP_COMMUNICATION_STATE)), ) diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index b639243..246fac9 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -102,6 +102,13 @@ (TYPE_BESS, NODE_INFO, "serial-number", "battery.serial_number"), (TYPE_BESS, NODE_INFO, "firmware-version", "battery.software_version"), (TYPE_BESS, NODE_INFO, "nameplate-capacity", "battery.nameplate_capacity_kwh"), + # The BESS's own meter and its own link health. `battery.power_w` carries a + # sign flip (`build_battery` reports charge-positive, the wire is + # charge-negative), which does not affect the unit or the datatype this row + # describes — a row states what the property *is*, not what the mapper does + # with it. + (TYPE_BESS, NODE_METER, "active-power", "battery.power_w"), + (TYPE_BESS, NODE_STATUS, "communication-state", "battery.communication_state"), # --- PV ------------------------------------------------------------------ (TYPE_PV, NODE_INFO, "vendor-name", "pv.vendor_name"), (TYPE_PV, NODE_INFO, "model", "pv.model"), diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 93d1c57..9a74fb8 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -163,6 +163,27 @@ class SpanBatterySnapshot: nameplate_capacity_kwh: float | None = None # bess/nameplate-capacity (kWh) connected: bool | None = None # bess/connected + # The BESS's own `meter/active-power`, v1.0 only. **Charge-positive**, which + # is a sign flip away from the wire: the enclosure meters the BESS the way it + # meters a circuit, so a charging battery reads negative there and positive + # here, exactly as `SpanCircuitSnapshot.instant_power_w` reports a load's + # consumption positive. The snapshot's rule across every power field is that + # positive means power flowing *into* the metered device. + # + # Distinct from `SpanPanelSnapshot.power_flow_battery`, which is the + # enclosure's own arbitrated flow figure and is passed through in the + # publisher's discharge-positive frame. The two describe the same physical + # power in opposite frames, so a consumer rendering both must negate one of + # them; this one is already negated. + power_w: float | None = None # v2: bess meter/active-power (W), charge-positive + + # `status/communication-state`, v1.0 only: the BESS publisher's report of its + # own link health (OK/DEGRADED/LOST/UNKNOWN). **Not** `connected`, which is + # the enclosure's `connection/fed-by-device-status` view of the same device. + # One is the device speaking about itself, the other the panel speaking about + # it, and the migration guide warns against conflating them. + communication_state: str | None = None # v2: bess status/communication-state + @dataclass(frozen=True, slots=True) class FieldMetadata: diff --git a/tests/test_schema_migration_delta.py b/tests/test_schema_migration_delta.py index eb4d363..675504b 100644 --- a/tests/test_schema_migration_delta.py +++ b/tests/test_schema_migration_delta.py @@ -164,6 +164,36 @@ matters — a field we thought was new turns out to be one users already have. """ +NEW_IN_V1_0: dict[str, str] = { + "battery.power_w": ( + "the BESS's own charge/discharge meter. `energy.ebus.capability.meter` on a " + "BESS device is new in v1.0 -- flat's `energy.ebus.device.bess` type declares no " + "`active-power` at all -- so nothing can orphan and no entity changes meaning. " + "The nearest flat figure is the enclosure's `power-flows/battery`, which both " + "schemas carry unchanged as `panel.power_flow_battery` and which is a different " + "property in the opposite sign frame" + ), + "battery.communication_state": ( + "the BESS publisher's report of its own link health. Flat's BESS type declares " + "`connected` and nothing else about the link, and `battery.connected` still " + "carries that -- from the enclosure's `connection/fed-by-device-status`, the " + "panel's view rather than the device's. Two views of one link, and v1.0 is the " + "first schema to publish the second" + ), +} +"""Additions with no flat property to have been re-sourced from. + +The bucket `PROVISIONAL_DER` is *not*: its members each have a flat property that +the frozen simulator happens not to send, so a real flat capture could reclassify +them as identity. These have no flat property in the schema at all, so no capture +can. `test_the_two_addition_buckets_are_told_apart_mechanically` asserts exactly +that distinction against `schema_0`'s field map rather than trusting this prose. + +A genuine addition is the benign kind of delta -- a new field cannot break an +automation that never referenced it -- but it still has to be *named*, or the +addition bucket becomes the place a surviving entity hides. +""" + PROVISIONAL_DER: frozenset[str] = frozenset( { "battery.model", @@ -448,11 +478,45 @@ def test_der_additions_are_provisional_or_attested_but_never_unexamined(flat: An found, _ = _classify(scope, before, after) additions |= found - accounted = set(PROVISIONAL_DER) | set(ATTESTED_AGAINST_FIRMWARE) + accounted = set(PROVISIONAL_DER) | set(ATTESTED_AGAINST_FIRMWARE) | set(NEW_IN_V1_0) assert additions == accounted, ( f"the DER addition set moved: {sorted(additions)}. Each member is either a field " - "the frozen simulator cannot vouch for or one real firmware has settled; a new one " - "needs deciding which, because 'addition' is the bucket that hides a surviving entity." + "the frozen simulator cannot vouch for, one real firmware has settled, or one v1.0 " + "introduces with no flat property behind it; a new one needs deciding which, " + "because 'addition' is the bucket that hides a surviving entity." + ) + + +def test_the_two_addition_buckets_are_told_apart_mechanically() -> None: + """`PROVISIONAL_DER` and `NEW_IN_V1_0` differ by a fact, not by a judgement. + + A provisional addition has a flat property behind it that the frozen simulator + does not publish, so a capture from real flat firmware could still move it to + *identity*. A v1.0 addition has no flat property at all, so no capture ever + will. `schema_0`'s `_PROPERTY_FIELD_MAP` is where that difference is recorded: + it holds a row for every flat property the mapper reads, whatever any given + capture contains. + + Asserted rather than described, because the whole value of splitting the + bucket is that membership is checkable. Put a genuinely new field in + `PROVISIONAL_DER` and this fails, which is the direction that matters -- + provisional means "expect this to become identity", and a field flat cannot + express is never going to. + """ + from span_panel_api_schema_0.field_metadata import _PROPERTY_FIELD_MAP + + flat_fields = {field_path for _, _, field_path in _PROPERTY_FIELD_MAP} + + unreachable = sorted(path for path in PROVISIONAL_DER if path not in flat_fields) + assert not unreachable, ( + f"provisional additions with no flat property behind them: {unreachable}. " + "Nothing can reclassify these as identity; they belong in NEW_IN_V1_0." + ) + + reachable = sorted(path for path in NEW_IN_V1_0 if path in flat_fields) + assert not reachable, ( + f"v1.0 additions that flat does have a property for: {reachable}. A flat " + "capture carrying it would make this an identity, so it is provisional, not new." ) diff --git a/tests/test_schema_one_adapter.py b/tests/test_schema_one_adapter.py index ef11a8b..23e416b 100644 --- a/tests/test_schema_one_adapter.py +++ b/tests/test_schema_one_adapter.py @@ -232,6 +232,13 @@ def test_field_metadata_takes_units_from_the_tree(adapter: SchemaOneAdapter) -> assert metadata["circuit.current_a"].unit == "A" assert metadata["panel.l1_voltage"].unit == "V" assert metadata["battery.soe_percentage"].unit == "%" + # The BESS's own meter and status nodes, mapped so the pair gets unit and + # datatype validation and the resolved/unresolved signal. The row describes + # the property; the sign flip `build_battery` applies is not a unit change. + assert metadata["battery.power_w"].unit == "W" + assert metadata["battery.power_w"].datatype == "float" + assert metadata["battery.communication_state"].unit is None + assert metadata["battery.communication_state"].datatype == "enum" def test_no_property_declares_an_abstract_unit() -> None: diff --git a/tests/test_schema_one_devices.py b/tests/test_schema_one_devices.py index f55e6ac..6fd193b 100644 --- a/tests/test_schema_one_devices.py +++ b/tests/test_schema_one_devices.py @@ -2,6 +2,9 @@ from __future__ import annotations +from collections.abc import Mapping +import json + import pytest from ebus_sdk.homie import DiscoveredDevice @@ -29,6 +32,53 @@ def _circuits() -> list[DiscoveredDevice]: return [_device(SOLAR_CIRCUIT), _device("0ab966b95f92a6a51ec548485aa85f54")] +BESS_POWER_TOPIC = "meter/active-power" +BESS_COMMS_TOPIC = "status/communication-state" + + +def _published(device_id: str, topic: str) -> str: + """What the capture publishes on this topic, or fail saying it does not. + + Every expectation below is computed from this rather than written as a + literal, so a test cannot keep passing against a fixture that stopped + carrying the value it is about. + """ + value = _TREE[device_id].get(topic) + assert value is not None, f"{device_id} publishes no {topic} in the capture" + return value + + +def _bess_with(overrides: Mapping[str, str | None]) -> DiscoveredDevice: + """The captured BESS with topics rewritten, or removed where the value is `None`. + + Removal is the point of the `None` case: a panel that stops publishing a + property retains nothing, which is a different event from publishing `""` + and has to produce a different answer. + """ + topics = dict(_TREE["bess"]) + for topic, value in overrides.items(): + if value is None: + topics.pop(topic, None) + else: + topics[topic] = value + return device_from_topics("bess", topics) + + +def _bess_without_node(node_id: str) -> DiscoveredDevice: + """The captured BESS with one capability node gone from its `$description`. + + The third shape of absence, and the one a fixture edit alone cannot reach: + hardware that never had the capability, as opposed to hardware that has it + and is not reporting. The `$description` is the authoritative property set, + so removing the node is what "this BESS has no meter" actually looks like. + """ + description = json.loads(_TREE["bess"]["$description"]) + del description["nodes"][node_id] + topics = {topic: value for topic, value in _TREE["bess"].items() if not topic.startswith(f"{node_id}/")} + topics["$description"] = json.dumps(description) + return device_from_topics("bess", topics) + + # --------------------------------------------------------------------------- # Topology — v1.0 states the relationship on the circuit, not the DER # --------------------------------------------------------------------------- @@ -120,6 +170,136 @@ def test_no_bess_yields_the_empty_battery_snapshot() -> None: assert battery.connected is None +# --------------------------------------------------------------------------- +# Battery power — the sign is the whole content of these +# --------------------------------------------------------------------------- + + +def test_the_capture_is_a_charging_battery() -> None: + """The premise of every sign assertion below, stated once and checked. + + A sign convention can only be tested against a known physical state. This + capture has the BESS charging: the enclosure meters it the way it meters a + circuit it feeds, so a battery drawing power reads *negative* there. Were the + capture ever recaptured with the battery discharging, this fails first and + says so, rather than the negation tests failing and reading as a mapper bug. + """ + assert float(_published("bess", BESS_POWER_TOPIC)) < 0 + + +def test_battery_power_is_charge_positive() -> None: + """The snapshot's frame: positive means power flowing into the metered device. + + Same rule as `SpanCircuitSnapshot.instant_power_w`, and reached the same way + -- `build_circuit` negates the enclosure's meter for a load, and a charging + BESS is a load. Asserting the magnitude and the sign separately is deliberate: + dropping the negation keeps the magnitude and fails here on the sign, which is + the mistake worth catching. + """ + raw = float(_published("bess", BESS_POWER_TOPIC)) + + battery = build_battery(_device("bess"), []) + + assert battery.power_w == -raw + assert battery.power_w is not None and battery.power_w > 0 + + +def test_battery_power_follows_a_republished_value() -> None: + """Proof the value is read off the wire and not defaulted into place.""" + raw = float(_published("bess", BESS_POWER_TOPIC)) + discharging = -raw / 2 + + battery = build_battery(_bess_with({BESS_POWER_TOPIC: str(discharging)}), []) + + # Charging became discharging, so the snapshot's sign flips with it. + assert battery.power_w == -discharging + assert battery.power_w is not None and battery.power_w < 0 + + +def test_a_battery_at_rest_reports_zero_and_not_negative_zero() -> None: + """`-0.0` compares equal to `0.0` and renders as "-0.0" beside it. + + `build_circuit` carries the same guard for the same reason; a negation added + without it produces a reading that looks broken exactly when nothing is + happening. + """ + battery = build_battery(_bess_with({BESS_POWER_TOPIC: "0.0"}), []) + + assert battery.power_w == 0.0 + assert str(battery.power_w) == "0.0" + + +def test_an_unpublished_battery_power_is_none_rather_than_zero() -> None: + """Zero is a reading — "the battery is idle" — and absence is not one.""" + assert build_battery(_bess_with({BESS_POWER_TOPIC: None}), []).power_w is None + + +def test_a_bess_with_no_meter_node_has_no_power() -> None: + assert build_battery(_bess_without_node("meter"), []).power_w is None + + +def test_the_bess_meter_and_the_enclosure_flow_agree_about_direction() -> None: + """The two properties describing this battery's power must not disagree. + + `panel.power_flow_battery` is the enclosure's own arbitrated figure and is + passed through untouched; `battery.power_w` is the BESS's own meter and is + negated. That is only coherent because the two properties are published in + the *same* frame on the wire -- asserted here rather than assumed, because a + consumer rendering both beside each other has to negate exactly one of them, + and which one is a fact about the capture rather than a preference. + """ + bess_meter = float(_published("bess", BESS_POWER_TOPIC)) + enclosure_flow = float(_published("example-40t-001", "power-flows/battery")) + + assert (bess_meter < 0) == (enclosure_flow < 0) + + +# --------------------------------------------------------------------------- +# Battery communication state +# --------------------------------------------------------------------------- + + +def test_communication_state_is_the_published_enum() -> None: + """Kept as the published string: DEGRADED is neither OK nor LOST, so a bool + would have to pick one, and `connected` is already the bool answer to the + other question.""" + published = _published("bess", BESS_COMMS_TOPIC) + + assert build_battery(_device("bess"), []).communication_state == published + + +def test_communication_state_follows_a_republished_value() -> None: + published = _published("bess", BESS_COMMS_TOPIC) + declared = json.loads(_TREE["bess"]["$description"])["nodes"]["status"]["properties"] + options = declared[BESS_COMMS_TOPIC.split("/", 1)[1]]["format"].split(",") + other = next(option for option in options if option != published) + + assert build_battery(_bess_with({BESS_COMMS_TOPIC: other}), []).communication_state == other + + +def test_an_unpublished_communication_state_is_none() -> None: + """`""` would read as a device reporting an empty answer; it reported nothing.""" + assert build_battery(_bess_with({BESS_COMMS_TOPIC: None}), []).communication_state is None + + +def test_a_bess_with_no_status_node_has_no_communication_state() -> None: + assert build_battery(_bess_without_node("status"), []).communication_state is None + + +def test_communication_state_and_connected_are_independent() -> None: + """The two link facts this task deliberately keeps apart. + + The BESS reports its own link `LOST` while the enclosure still claims it as + `OK`: one is the device speaking about itself, the other the panel speaking + about it, and a mapping that conflated them would make this impossible to + express. + """ + battery = build_battery(_bess_with({BESS_COMMS_TOPIC: "LOST"}), [_device("lugs-upstream")]) + + assert battery.communication_state == "LOST" + assert battery.connected is True + + # --------------------------------------------------------------------------- # PV # --------------------------------------------------------------------------- From 64476bb21e6272ef657c8074125ab78b0238b482 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:35:20 -0700 Subject: [PATCH 082/115] test(schema-1): derive the charging premise instead of assuming it `test_the_capture_is_a_charging_battery` asserted the sign of the property whose sign convention is the thing under test, which is circular: it would pass on a recaptured discharging battery just as happily, and take the negation tests down with it while reading as a mapper bug. The enclosure's four power flows balance -- `pv + battery + grid == site`, with `grid` positive when importing -- so solving that identity says which way the battery is going without appealing to any convention this library chose. 8500 W of PV meets 2653 W of site load and exports 2347 W; the 3500 W left over is going into the battery. Charging, published negative, established from arithmetic. --- tests/test_schema_one_devices.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/tests/test_schema_one_devices.py b/tests/test_schema_one_devices.py index 6fd193b..8c76d9e 100644 --- a/tests/test_schema_one_devices.py +++ b/tests/test_schema_one_devices.py @@ -176,14 +176,31 @@ def test_no_bess_yields_the_empty_battery_snapshot() -> None: def test_the_capture_is_a_charging_battery() -> None: - """The premise of every sign assertion below, stated once and checked. - - A sign convention can only be tested against a known physical state. This - capture has the BESS charging: the enclosure meters it the way it meters a - circuit it feeds, so a battery drawing power reads *negative* there. Were the - capture ever recaptured with the battery discharging, this fails first and - says so, rather than the negation tests failing and reading as a mapper bug. + """The premise of every sign assertion below, derived rather than assumed. + + A sign convention can only be tested against a known physical state, and + "negative means charging" is the claim under test, so reading the state off + the sign would be circular. The enclosure's four power flows balance instead + -- ``pv + battery + grid == site``, with ``grid`` positive when importing -- + and solving that identity says which way the battery is going without + appealing to any convention this library chose. + + In this capture 8500 W of PV meets 2653 W of site load and exports 2347 W; + the 3500 W left over is going into the battery. So the battery is charging, + and both the enclosure and the BESS publish that as a negative number. + + Were the capture ever retaken with the battery discharging, this fails first + and says so, rather than the negation tests failing and reading as a mapper + bug. """ + flows = {name: float(_published("example-40t-001", f"power-flows/{name}")) for name in ("pv", "battery", "grid", "site")} + + assert flows["pv"] + flows["battery"] + flows["grid"] == pytest.approx(flows["site"]) + # PV alone exceeds the site load, so the surplus has nowhere to go but the + # battery and the grid -- and the grid term is an export. + assert flows["pv"] > flows["site"] + assert flows["grid"] < 0 + assert flows["battery"] < 0 assert float(_published("bess", BESS_POWER_TOPIC)) < 0 From 6ec0e45d6534bb0170b335fecc5b5164e9e71410 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:53:13 -0700 Subject: [PATCH 083/115] feat(schema-1): read the enclosure's power control system into the snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `energy.ebus.capability.pcs` 0.3 is the largest capability the enclosure publishes and nothing read a byte of it: sixteen properties on the panel, two on every circuit. The panel half is UL 3141 import limiting — the Firm Service Rating plus the arbitration that reconciles every active import constraint to one enforced current limit — and the circuit half is that circuit's participation in it. `SpanPcsSnapshot` is a nested type on `SpanPanelSnapshot.pcs` rather than sixteen optional fields on the enclosure, for the reason `SpanMidSnapshot` is one: presence becomes `snapshot.pcs is not None`, with nothing to infer from a sentinel. Here the capability states the absence rule itself — "absence of the `pcs` node means the device does not run (or participate in) a Power Control System" — and the reference capture is exactly the case that needs it: a PCS that exists and is switched off, publishing `0.0` on every limit. Sixteen `None`s on the enclosure could not tell that apart from a panel with no PCS, and `0.0` is a legal reading meaning no import is permitted. So `build_pcs` gates on the **declaration**, not on a value. `declares_node` is the general form of that question: a capability whose every property is legally zero cannot be detected by reading it. The four constraint classes publish an identical `{-import-limit, -enablement, -active}` triplet, and the catalog says that is a rule rather than a coincidence — a vendor "MAY publish further amps-native limits using the same triplet". `_limit_triplet` reads one, so a fifth source is one line rather than three. `binding-constraint` and the enablements stay raw wire strings: both are extensible through the publisher's `$format`, and `binding-constraint` exists to name a source, so normalising it onto a set fixed here would discard the extension it was designed to carry. `_PROPERTY_FIELD_MAP` gains rows for only what the catalog calls "the result" — `import-limit`, `binding-constraint` — plus `active`, which decides whether there is a result. The four families and `enabled` qualify that result rather than standing as readings, the same treatment `shed-forecast`'s full-charge pair gets, and a unit row for them would advertise a surface that is not there. `SpanCircuitSnapshot.pcs_managed` / `pcs_priority` are `None`, never `False`/`0`: both properties are `MAY`, a circuit that has not said it is managed has not said it is unmanaged, and `0` is a legal ranking. `pcs_priority` is deliberately not `priority` — one is an integer shed ordering under an import limit, the other the backup tier, and the catalog keeps the two policies apart because a circuit may participate in one, both, or neither. **Every reading is proved by mutation, because the capture cannot prove one on its own.** All sixteen panel values are zeros, `false` and `UNCONFIGURED`, so an assertion that a field equals what was published is satisfied by fifteen wrong wirings as easily as by the right one. Each property is instead republished with a value distinct from the captured one and from every sibling's, one at a time, and the other fifteen fields are pinned to their baseline — a field reading its neighbour moves when it should not. Crossing two families fails eight tests; making the presence gate value-based fails forty; defaulting one limit to `0.0` fails three; wiring the circuit's PCS priority to its load-shed priority fails three. Purely additive: no flat panel publishes this capability, so `pcs` is `None` on every existing consumer's data and nothing that reads the snapshot today changes. --- .../src/span_panel_api_schema_1/circuits.py | 39 ++ .../src/span_panel_api_schema_1/const.py | 22 + .../span_panel_api_schema_1/field_metadata.py | 17 + .../src/span_panel_api_schema_1/panel.py | 118 +++- .../src/span_panel_api_schema_1/snapshot.py | 5 + src/span_panel_api/__init__.py | 2 + src/span_panel_api/models.py | 137 +++++ tests/test_public_api_unchanged.py | 6 + tests/test_schema_one_pcs.py | 522 ++++++++++++++++++ 9 files changed, 866 insertions(+), 2 deletions(-) create mode 100644 tests/test_schema_one_pcs.py diff --git a/packages/schema-1/src/span_panel_api_schema_1/circuits.py b/packages/schema-1/src/span_panel_api_schema_1/circuits.py index 6d4de94..b6ec879 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/circuits.py +++ b/packages/schema-1/src/span_panel_api_schema_1/circuits.py @@ -32,12 +32,14 @@ NODE_INFO, NODE_LOAD_SHED, NODE_METER, + NODE_PCS, NODE_SWITCH, PRIORITY_NEVER, PROP_ACTIVE_POWER, PROP_CURRENT, PROP_EXPORTED_ENERGY, PROP_IMPORTED_ENERGY, + PROP_MANAGED, PROP_NAME, PROP_POLES, PROP_PRIORITY, @@ -88,6 +90,32 @@ def _flag(device: DiscoveredDevice, node: str, prop: str, *, default: bool) -> b return str(raw).strip().lower() == "true" +def _optional_flag(device: DiscoveredDevice, node: str, prop: str) -> bool | None: + """A boolean that distinguishes "published false" from "not published". + + `_flag` above collapses the two onto a caller-chosen default, which is right + for the relay properties where absence has a defined meaning. It is wrong + for `pcs/managed`, which the capability marks `MAY`: a circuit that says + nothing about PCS participation has not said it is unmanaged, and reporting + `False` would put that claim on a dashboard. + """ + raw = device.get_property(node, prop) + if raw is None or raw == "": + return None + return str(raw).strip().lower() == "true" + + +def _optional_integer(device: DiscoveredDevice, node: str, prop: str) -> int | None: + """An `integer` property, or `None` when it is absent or unparseable. + + Parsed through `_number` for the reason `panel.integer` gives: a publisher + sending `1.0` for an integer property has made a formatting choice, not + withheld a reading. + """ + raw = _number(device, node, prop) + return None if raw is None else int(raw) + + def _tabs(device: DiscoveredDevice) -> list[int]: """Breaker spaces from ``info/spaces``. @@ -174,4 +202,15 @@ def build_circuit( relay_requester=_text(device, NODE_SWITCH, PROP_RELAY_REQUESTER, UNKNOWN), relay_state_target=device.get_property_target(NODE_SWITCH, PROP_RELAY), priority_target=device.get_property_target(NODE_LOAD_SHED, PROP_PRIORITY), + # The circuit half of `energy.ebus.capability.pcs`: participation only. + # The arbitration that decides the enforced limit is the enclosure's, + # and lands on `SpanPanelSnapshot.pcs`. + # + # `pcs/priority` is *not* `load-shed/priority` above, and the two share + # neither a value space nor a purpose: this one is an integer shed + # ordering under an import limit, that one is the backup tier + # (`MUST_HAVE` / `NON_ESSENTIAL` / …). A circuit may participate in one + # policy, both, or neither, so they are read separately and named apart. + pcs_managed=_optional_flag(device, NODE_PCS, PROP_MANAGED), + pcs_priority=_optional_integer(device, NODE_PCS, PROP_PRIORITY), ) diff --git a/packages/schema-1/src/span_panel_api_schema_1/const.py b/packages/schema-1/src/span_panel_api_schema_1/const.py index 8954bb7..787eef9 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/const.py +++ b/packages/schema-1/src/span_panel_api_schema_1/const.py @@ -62,6 +62,28 @@ # shed node PROP_ASSERTED_ISLANDING_STATE = "asserted-islanding-state" +# pcs node. `energy.ebus.capability.pcs` 0.3 publishes two disjoint property +# sets under one node type: the enclosure runs the arbitration and publishes the +# *system* surface, while a circuit publishes only its *participation*. Same +# capability, different publishers — the same split `meter` makes between the +# panel, a circuit and a lugs device. +PROP_ENABLED = "enabled" +PROP_ACTIVE = "active" +PROP_IMPORT_LIMIT = "import-limit" +PROP_BINDING_CONSTRAINT = "binding-constraint" +PROP_MANAGED = "managed" + +# The amps-native constraint classes the enclosure reconciles, in the catalog's +# order. Each publishes the same `{-import-limit, -enablement, -active}` +# triplet, and the catalog is explicit that "the number and naming of sources is +# not fixed by this spec": a vendor may publish further sources using the same +# shape. A tuple of prefixes rather than twelve constants is what lets the +# reader below be written once per triplet member instead of once per source. +PCS_LIMIT_SOURCES: tuple[str, ...] = ("feed", "operator", "off-grid", "requested") +PCS_LIMIT_SUFFIX = "-import-limit" +PCS_ENABLEMENT_SUFFIX = "-enablement" +PCS_ACTIVE_SUFFIX = "-active" + # shed-forecast node. All four times are `integer` minutes; `confidence` is the # enum LOW/MEDIUM/HIGH qualifying them. The `full-charge-*` pair answers the # hypothetical "if the BESS were full", so it is a capability figure rather than diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index 246fac9..31f25e1 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -25,6 +25,7 @@ NODE_INFO, NODE_LOAD_SHED, NODE_METER, + NODE_PCS, NODE_POWER_FLOWS, NODE_SHED_FORECAST, NODE_SOC, @@ -76,6 +77,22 @@ # these reports it as degradation rather than as absent hardware. (TYPE_PANEL, NODE_SHED_FORECAST, "time-to-priority-shed", "panel.shed_time_to_priority_shed_min"), (TYPE_PANEL, NODE_SHED_FORECAST, "total-time-remaining", "panel.shed_total_time_remaining_min"), + # --- Panel `pcs` → pcs.* ------------------------------------------------- + # Only the three the capability calls "the result", plus the state that + # decides whether there is a result at all. `capabilities/pcs.md` is + # explicit that `pcs` does not re-publish the other regimes' constraints: + # "what `pcs` publishes is the **result**: the effective `import-limit` and + # the `binding-constraint`". Those are what a consumer renders as readings, + # so those are what carry a unit row — `import-limit` in particular, whose + # `A` is validated against the sensor's declared unit. + # + # The four constraint families and `enabled` are read into the snapshot too + # and deliberately have no row: they qualify the effective limit rather than + # standing as readings, exactly as the `shed-forecast` full-charge pair + # does, and a unit row for them would advertise a surface that is not there. + (TYPE_PANEL, NODE_PCS, "import-limit", "pcs.import_limit_a"), + (TYPE_PANEL, NODE_PCS, "binding-constraint", "pcs.binding_constraint"), + (TYPE_PANEL, NODE_PCS, "active", "pcs.active"), # --- Lugs → panel.* ------------------------------------------------------ # Deliberately absent. Which device a lugs property belongs to comes from # `info/direction` at read time, and a table keyed on (type, node, property) diff --git a/packages/schema-1/src/span_panel_api_schema_1/panel.py b/packages/schema-1/src/span_panel_api_schema_1/panel.py index 3e1cdc6..f937b22 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/panel.py +++ b/packages/schema-1/src/span_panel_api_schema_1/panel.py @@ -23,9 +23,9 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple -from span_panel_api.models import SpanCircuitSnapshot +from span_panel_api.models import SpanCircuitSnapshot, SpanPcsSnapshot from span_panel_api_schema_1.const import ( CLOUD_CONNECTED, NODE_BREAKER, @@ -34,22 +34,30 @@ NODE_GRID_FORMING, NODE_INFO, NODE_METER, + NODE_PCS, NODE_POWER_FLOWS, NODE_SHED, NODE_SHED_FORECAST, NODE_STATUS, PANEL_SIZE_BY_MODEL, + PCS_ACTIVE_SUFFIX, + PCS_ENABLEMENT_SUFFIX, + PCS_LIMIT_SUFFIX, + PROP_ACTIVE, PROP_ACTIVE_POWER, PROP_ASSERTED_ISLANDING_STATE, + PROP_BINDING_CONSTRAINT, PROP_CAPABLE, PROP_CLOUD_CONNECTION, PROP_CONFIDENCE, + PROP_ENABLED, PROP_ETHERNET, PROP_EXPORTED_ENERGY, PROP_FIRMWARE_VERSION, PROP_FULL_CHARGE_TIME_TO_PRIORITY_SHED, PROP_FULL_CHARGE_TOTAL_TIME_REMAINING, PROP_GRID_FORMING_ENTITY, + PROP_IMPORT_LIMIT, PROP_IMPORTED_ENERGY, PROP_MODEL, PROP_RATING, @@ -145,6 +153,28 @@ def optional_flag(device: DiscoveredDevice | None, node: str, prop: str) -> bool return raw == "true" +def declares_node(device: DiscoveredDevice | None, node: str) -> bool: + """Whether a device's `$description` declares a capability node at all. + + The presence question a value cannot answer. A capability whose properties + are every one of them legally zero — `pcs` is the worked example — cannot be + detected by reading them, and a consumer that gates entity creation on a + value would delete a switched-off PCS's entities rather than showing it + switched off. + + The `$description` is the right place to ask, per the migration guide's rule + that "the authoritative property set for any capability node is always + declared in that device's `$description`". A node declared with no + properties still counts as declared: that is a degraded publisher, which + `build_field_metadata` reports as `resolved=False`, not absent hardware. + """ + if device is None: + return False + description: dict[str, object] = device.description or {} + nodes = description.get("nodes") + return isinstance(nodes, dict) and node in nodes + + def panel_size_from_model(model: str) -> int: """Total breaker spaces for a panel model, or 0 when the model is unknown. @@ -354,6 +384,90 @@ def __init__( self.shed_forecast_confidence = text(panel, NODE_SHED_FORECAST, PROP_CONFIDENCE) or None +class _LimitTriplet(NamedTuple): + """One constraint class's `{limit, enablement, active}` triplet, as published. + + Named rather than a bare tuple because the three members are a float, a + string and a boolean read from three sibling properties, and positional + unpacking at four call sites is how an enablement ends up in an active flag. + """ + + limit_a: float | None + enablement: str | None + active: bool | None + + +def _limit_triplet(panel: DiscoveredDevice, source: str) -> _LimitTriplet: + """Read one amps-native constraint class off the enclosure's `pcs` node. + + Every source in `PCS_LIMIT_SOURCES` publishes the identical + `{-import-limit, -enablement, -active}` shape, which the capability + states as a rule rather than as a coincidence: a vendor "MAY publish further + amps-native limits using the same triplet". Reading them through one + function is what makes a fifth source a one-line addition instead of three. + + All three are optional independently. A publisher that reports a limit and + no enablement is conformant, and reporting `UNCONFIGURED` on its behalf + would invent a configuration state it never claimed. + """ + prefix = f"{source}{PCS_LIMIT_SUFFIX}" + return _LimitTriplet( + limit_a=number(panel, NODE_PCS, prefix), + enablement=text(panel, NODE_PCS, f"{prefix}{PCS_ENABLEMENT_SUFFIX}") or None, + active=optional_flag(panel, NODE_PCS, f"{prefix}{PCS_ACTIVE_SUFFIX}"), + ) + + +def build_pcs(panel: DiscoveredDevice) -> SpanPcsSnapshot | None: + """The enclosure's Power Control System, or `None` when it publishes no `pcs` node. + + Gated on the **declaration**, not on any value, because the capability + defines absence that way: "absence of the `pcs` node means the device does + not run (or participate in) a Power Control System". Every limit in the + reference capture is `0.0` with `UNCONFIGURED` enablement — a PCS that + exists and is switched off — and a value-based gate could not tell that from + a panel with no PCS at all. One is a capability reporting its state; the + other is hardware that is not there. + + Every field stays `None` where the node omits the property. The catalog + marks the system surface `SHOULD` and three of the four constraint classes + `MAY`, so a partial node is conformant firmware rather than a fault, and a + limit defaulted to `0.0` would read as "no import permitted" — the most + alarming reading the property has. + + Enablement and `binding-constraint` are kept as raw wire strings. Both are + enums the publisher may extend through its Homie `$format`, and + `binding-constraint` exists precisely to name a source, so normalising it + onto a set fixed here would discard the extension it was designed to carry. + """ + if not declares_node(panel, NODE_PCS): + return None + + feed = _limit_triplet(panel, "feed") + operator = _limit_triplet(panel, "operator") + off_grid = _limit_triplet(panel, "off-grid") + requested = _limit_triplet(panel, "requested") + + return SpanPcsSnapshot( + enabled=optional_flag(panel, NODE_PCS, PROP_ENABLED), + active=optional_flag(panel, NODE_PCS, PROP_ACTIVE), + import_limit_a=number(panel, NODE_PCS, PROP_IMPORT_LIMIT), + binding_constraint=text(panel, NODE_PCS, PROP_BINDING_CONSTRAINT) or None, + feed_import_limit_a=feed.limit_a, + feed_import_limit_enablement=feed.enablement, + feed_import_limit_active=feed.active, + operator_import_limit_a=operator.limit_a, + operator_import_limit_enablement=operator.enablement, + operator_import_limit_active=operator.active, + off_grid_import_limit_a=off_grid.limit_a, + off_grid_import_limit_enablement=off_grid.enablement, + off_grid_import_limit_active=off_grid.active, + requested_import_limit_a=requested.limit_a, + requested_import_limit_enablement=requested.enablement, + requested_import_limit_active=requested.active, + ) + + # Matches `schema_0`'s epsilon so the no-MID heuristic answers identically on the two # adapters — the tier exists precisely for panels where nothing authoritative is # published, and disagreeing about the threshold would make it schema-dependent. diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index bb11481..7113609 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -28,6 +28,7 @@ from span_panel_api_schema_1.devices import build_battery, build_evse, build_mid, build_pv, feed_circuit_ids from span_panel_api_schema_1.panel import ( PanelFields, + build_pcs, build_unmapped_tabs, find_lugs, panel_size_from_model, @@ -185,6 +186,10 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re battery=build_battery(roles.bess, owners), pv=build_pv(roles.pv, feeds, upstream, downstream), mid=build_mid(roles.mid, device_names), + # Gated on the node being declared, not on any value: every limit this + # capability publishes is legally `0.0`, so there is no reading that can + # distinguish a switched-off PCS from an absent one. See `build_pcs`. + pcs=build_pcs(panel), evse={key: build_evse(device, feeds, node_id=key) for device, key in _harmonised_evse_keys(roles.evse).items()}, ) diff --git a/src/span_panel_api/__init__.py b/src/span_panel_api/__init__.py index d231312..584ce70 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -38,6 +38,7 @@ SpanEvseSnapshot, SpanMidSnapshot, SpanPanelSnapshot, + SpanPcsSnapshot, SpanPVSnapshot, V2AuthResponse, V2HomieSchema, @@ -79,6 +80,7 @@ "SpanMidSnapshot", "SpanPVSnapshot", "SpanPanelSnapshot", + "SpanPcsSnapshot", # Factory "create_span_client", # Detection diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 9a74fb8..df758cf 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -45,6 +45,23 @@ class SpanCircuitSnapshot: relay_state_target: str | None = None # v2: $target for relay (desired state) priority_target: str | None = None # v2: $target for shed-priority (desired state) + # This circuit's *participation* in the enclosure's Power Control System — + # `energy.ebus.capability.pcs` 0.3, the half a circuit publishes. The + # system half (the effective limit and its arbitration) is on the enclosure + # and lands on `SpanPanelSnapshot.pcs`; a circuit says only whether the PCS + # manages it and where it sits in the shed order. + # + # `None` on both, never `False`/`0`, because both are `MAY` and a circuit + # that says nothing is not the same as one that says no: priority `0` is a + # legal ranking, and "unmanaged" is a claim the panel has to make. + # + # Distinct from `priority`/`is_sheddable`, which are `load-shed` — a + # different policy on the same relay. The catalog keeps them apart because + # they answer different questions (limit site import versus preserve backup + # runtime) and a circuit may participate in one, both, or neither. + pcs_managed: bool | None = None # v2: circuit pcs/managed + pcs_priority: int | None = None # v2: circuit pcs/priority + @dataclass(frozen=True, slots=True) class SpanPVSnapshot: @@ -128,6 +145,117 @@ class SpanMidSnapshot: """ +@dataclass(frozen=True, slots=True) +class SpanPcsSnapshot: + """The enclosure's Power Control System — UL 3141 import limiting. v1.0 only. + + A `pcs` node runs one physical actuator and two roles, per + `capabilities/pcs.md` 0.3: it is the premises-equipment protection (the Firm + Service Rating), and it is the arbitrator that reconciles *every* active + import constraint to one enforced current limit. The constraints arrive in + different native units on different capabilities — amps here, watts on + `doe`, volts on `voltage-response` — and `pcs` does not re-publish them as + amps copies. **What it publishes is the result**: the effective + `import-limit` and the `binding-constraint` naming which class won the + `min()`. + + That sentence is the shape of this type. `import_limit_a` and + `binding_constraint` are the answer; the four `{feed,operator,off_grid, + requested}_import_limit_*` families are the inputs that produced it, kept + beside the answer so a consumer can explain a number rather than only show + it. + + **A nested type rather than sixteen optional fields on the panel**, for the + reason `SpanMidSnapshot` is one: presence is `snapshot.pcs is not None`, + with nothing to infer from a sentinel. `capabilities/pcs.md` states the + absence rule outright — "absence of the `pcs` node means the device does not + run (or participate in) a Power Control System" — so there is a real + distinction between a panel with no PCS and a PCS reporting zeros, and + sixteen `None`s on the enclosure could not carry it. + + **Flat is the absence case, not a translation problem.** No flat panel + publishes `energy.ebus.capability.pcs` at all, so nothing here can orphan an + entity a user already has. + + Every member is optional because every property in the catalog is `SHOULD` + or `MAY`: a conformant publisher populates whichever constraint classes + apply to its equipment and omits the rest. `None` therefore means "this + panel does not report it", which is a different statement from a limit of + `0.0` — and `0.0` is a legal, meaningful reading (no import permitted), so + no field may default to it. + """ + + enabled: bool | None = None + """`pcs/enabled` — is the PCS enabled on this enclosure at all?""" + active: bool | None = None + """`pcs/active` — is it limiting import *right now*? + + Distinct from `enabled`: a configured PCS spends most of its life enabled + and inactive, and this is the transition an automation triggers on. + """ + import_limit_a: float | None = None + """`pcs/import-limit` (A) — the effective enforced limit, the `min()` result. + + The single number that summarises the capability, and the only one that + reflects the reconciled `doe` and `voltage-response` constraints as well as + the amps-native families below. + """ + binding_constraint: str | None = None + """`pcs/binding-constraint` — which class currently sets `import_limit_a`. + + The catalog enum is `FSR`, `DOE`, `VOLTAGE`, `OFF_GRID`, `REQUESTED`, + `OPERATOR`, `NONE`, `UNKNOWN`, and publishers **MAY extend it** through the + property's Homie `$format`. Kept as the raw wire string for that reason: a + re-encoding onto a closed set defined here would drop a vendor's extension + on the floor, and this is the property whose whole job is naming a source. + """ + + feed_import_limit_a: float | None = None + """`pcs/feed-import-limit` (A) — the FSR: the commissioned, always-on floor. + + May be below the main-breaker rating where the service feed is smaller than + the panel; the catalog's example is a 200 A panel on a 100 A feed. + """ + feed_import_limit_enablement: str | None = None + """`pcs/feed-import-limit-enablement` — `UNSPECIFIED`, `UNCONFIGURED`, `DISABLED`, `ENABLED`.""" + feed_import_limit_active: bool | None = None + """`pcs/feed-import-limit-active` — is this constraint enforcing? + + Distinct from `binding_constraint`, and deliberately: several constraints + can be active at once, and only the most restrictive is binding. + """ + + operator_import_limit_a: float | None = None + """`pcs/operator-import-limit` (A) — an externally imposed fleet/aggregator cap. + + Set over the vendor's management API and persisting until the operator + changes it — not the standardised IEEE 2030.5 watts envelope, which lives + on `doe`. + """ + operator_import_limit_enablement: str | None = None + """`pcs/operator-import-limit-enablement` — same enum domain as the feed family.""" + operator_import_limit_active: bool | None = None + """`pcs/operator-import-limit-active` — is the operator cap enforcing?""" + + off_grid_import_limit_a: float | None = None + """`pcs/off-grid-import-limit` (A) — the import cap while islanded.""" + off_grid_import_limit_enablement: str | None = None + """`pcs/off-grid-import-limit-enablement` — same enum domain as the feed family.""" + off_grid_import_limit_active: bool | None = None + """`pcs/off-grid-import-limit-active` — typically true only while islanded.""" + + requested_import_limit_a: float | None = None + """`pcs/requested-import-limit` (A) — a voluntary, self-revocable user limit. + + Requested by the homeowner or installer through the vendor's app. Distinct + from the operator cap, which the site cannot revoke. + """ + requested_import_limit_enablement: str | None = None + """`pcs/requested-import-limit-enablement` — same enum domain as the feed family.""" + requested_import_limit_active: bool | None = None + """`pcs/requested-import-limit-active` — is the voluntary limit enforcing?""" + + @dataclass(frozen=True, slots=True) class SpanEvseSnapshot: """EV Charger (EVSE) state — populated when EVSE node is commissioned.""" @@ -391,3 +519,12 @@ class SpanPanelSnapshot: signal. A new optional device should not inherit that: presence is `snapshot.mid is not None`, with nothing to infer. """ + pcs: SpanPcsSnapshot | None = None + """The enclosure's Power Control System, when it publishes a `pcs` node. v1.0 only. + + `None` follows `mid` for the same reason, and here the capability states the + rule itself: "absence of the `pcs` node means the device does not run (or + participate in) a Power Control System". A panel with no PCS and a PCS + holding zeros are different facts, and only a nullable member can tell them + apart — every limit in this capture is a legal `0.0`. + """ diff --git a/tests/test_public_api_unchanged.py b/tests/test_public_api_unchanged.py index 4aec4c4..430ceb2 100644 --- a/tests/test_public_api_unchanged.py +++ b/tests/test_public_api_unchanged.py @@ -36,6 +36,12 @@ "SpanMidSnapshot", "SpanPVSnapshot", "SpanPanelSnapshot", + # Added 2026-08-19: the enclosure's Power Control System (UL 3141 import + # limiting). Purely additive for the same reason as the MID -- no flat panel + # publishes `energy.ebus.capability.pcs`, so `SpanPanelSnapshot.pcs` is + # `None` on every existing consumer's data and nothing that reads the + # snapshot today changes. + "SpanPcsSnapshot", # Factory "create_span_client", # Detection diff --git a/tests/test_schema_one_pcs.py b/tests/test_schema_one_pcs.py new file mode 100644 index 0000000..7dc611c --- /dev/null +++ b/tests/test_schema_one_pcs.py @@ -0,0 +1,522 @@ +"""The enclosure's Power Control System, from the wire to the snapshot. + +`pcs` 0.3 is the largest single capability the enclosure publishes: sixteen +properties on the panel and two on every circuit. The enclosure runs the +arbitration and publishes the *system* surface; a circuit publishes only its +*participation*. + +**The capture is a PCS that is switched off, and that shapes every test here.** +Every limit is `0.0`, every enablement `UNCONFIGURED`, every boolean `false`. +Uniform data makes an assertion cheap to satisfy for the wrong reason: a field +wired to the neighbouring property reports the identical value, and a parser +that returned a zero of its own would agree with the wire by accident. So no +test in this module rests on the captured values alone. Presence is asserted +against the capture, and every *reading* is proved by republishing a value that +differs from the captured one and from every sibling's, one property at a time, +with the other fifteen fields pinned to their baseline. A field that read the +wrong property moves when it should not, and that is what fails. +""" + +from __future__ import annotations + +import dataclasses +import json +from typing import Any + +import pytest + +from ebus_sdk.homie import DiscoveredDevice + +from span_panel_api.models import FieldMetadata, SpanPanelSnapshot, SpanPcsSnapshot +from span_panel_api_schema_1.const import PCS_LIMIT_SOURCES +from span_panel_api_schema_1.field_metadata import build_field_metadata +from span_panel_api_schema_1.reference_payloads import ( + RetainedTopicTree, + device_from_topics, + parent_child_tree, +) +from span_panel_api_schema_1.snapshot import build_snapshot + +PANEL = "example-40t-001" +NODE = "pcs" + +# A circuit the capture publishes participation for, and one that opts out. Two +# so a mapper that reported a constant cannot satisfy both. +MANAGED_CIRCUIT = "0ab966b95f92a6a51ec548485aa85f54" +UNMANAGED_CIRCUIT = "573066aaddd7b75114c4563ce3af18c4" + + +def _mutable_tree() -> dict[str, dict[str, str]]: + return {device_id: dict(topics) for device_id, topics in parent_child_tree().items()} + + +def _snapshot(tree: RetainedTopicTree) -> SpanPanelSnapshot: + panel = device_from_topics(PANEL, tree[PANEL]) + children = [device_from_topics(device_id, topics) for device_id, topics in tree.items() if device_id != PANEL] + return build_snapshot(panel, children) + + +def _devices(tree: RetainedTopicTree) -> list[DiscoveredDevice]: + return [device_from_topics(device_id, topics) for device_id, topics in tree.items()] + + +def _pcs(tree: RetainedTopicTree) -> SpanPcsSnapshot: + """The snapshot's PCS, or fail saying the panel carries none. + + Narrowing here rather than at each call site: `pcs` is optional by design, + and every test below that reaches into it has already asserted, or is + asserting, that the capture publishes the node. + """ + pcs = _snapshot(tree).pcs + assert pcs is not None, "the capture's panel carries no pcs snapshot" + return pcs + + +def _published(property_id: str) -> str: + return parent_child_tree()[PANEL][f"{NODE}/{property_id}"] + + +def _declared_properties(tree: RetainedTopicTree, device_id: str = PANEL) -> dict[str, Any]: + description: dict[str, Any] = json.loads(tree[device_id]["$description"]) + node: dict[str, Any] = description["nodes"][NODE] + properties: dict[str, Any] = node["properties"] + return properties + + +def _without_property(tree: dict[str, dict[str, str]], property_id: str) -> dict[str, dict[str, str]]: + """Stop publishing one PCS property, and stop declaring it too.""" + del tree[PANEL][f"{NODE}/{property_id}"] + description = json.loads(tree[PANEL]["$description"]) + del description["nodes"][NODE]["properties"][property_id] + tree[PANEL]["$description"] = json.dumps(description) + return tree + + +def _without_node(tree: dict[str, dict[str, str]], device_id: str = PANEL) -> dict[str, dict[str, str]]: + """A device that publishes no `pcs` node at all.""" + for topic in [topic for topic in tree[device_id] if topic.startswith(f"{NODE}/")]: + del tree[device_id][topic] + description = json.loads(tree[device_id]["$description"]) + del description["nodes"][NODE] + tree[device_id]["$description"] = json.dumps(description) + return tree + + +# Every panel property the capability publishes, paired with the field that +# reads it and with a republished value chosen to be distinct from the captured +# one *and* from every sibling's. Distinctness is the whole apparatus: against a +# capture where all sixteen values are zeros, `false` and `UNCONFIGURED`, an +# assertion that a field equals what was published is satisfied by fifteen wrong +# wirings as easily as by the right one. +# +# The enablement enum has exactly four members and there are exactly four +# constraint classes, so each family gets a different one; the limits get +# unrelated decimals; and each boolean is flipped away from the captured value. +_PANEL_READS: tuple[tuple[str, str, str, object], ...] = ( + ("enabled", "enabled", "true", True), + ("active", "active", "true", True), + ("import-limit", "import_limit_a", "55.5", 55.5), + ("binding-constraint", "binding_constraint", "FSR", "FSR"), + ("feed-import-limit", "feed_import_limit_a", "11.5", 11.5), + ("feed-import-limit-enablement", "feed_import_limit_enablement", "ENABLED", "ENABLED"), + ("feed-import-limit-active", "feed_import_limit_active", "true", True), + ("operator-import-limit", "operator_import_limit_a", "22.25", 22.25), + ("operator-import-limit-enablement", "operator_import_limit_enablement", "DISABLED", "DISABLED"), + ("operator-import-limit-active", "operator_import_limit_active", "true", True), + ("off-grid-import-limit", "off_grid_import_limit_a", "33.75", 33.75), + ("off-grid-import-limit-enablement", "off_grid_import_limit_enablement", "UNSPECIFIED", "UNSPECIFIED"), + ("off-grid-import-limit-active", "off_grid_import_limit_active", "true", True), + ("requested-import-limit", "requested_import_limit_a", "44.125", 44.125), + ( + "requested-import-limit-enablement", + "requested_import_limit_enablement", + "UNSPECIFIED", + "UNSPECIFIED", + ), + ("requested-import-limit-active", "requested_import_limit_active", "true", True), +) + +_PANEL_PROPERTIES = tuple(property_id for property_id, _, _, _ in _PANEL_READS) + + +def _fields(pcs: SpanPcsSnapshot) -> dict[str, object]: + return {field.name: getattr(pcs, field.name) for field in dataclasses.fields(pcs)} + + +# --------------------------------------------------------------------------- +# The premise: what the capture actually carries +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_the_whole_system_surface() -> None: + """Guard the premise. Sixteen properties, every one declared and published; + a capture that dropped one would make its absence test vacuous.""" + tree = parent_child_tree() + declared = _declared_properties(tree) + + assert set(declared) == set(_PANEL_PROPERTIES) + for property_id in _PANEL_PROPERTIES: + assert f"{NODE}/{property_id}" in tree[PANEL] + + +def test_the_capture_is_a_pcs_that_is_switched_off() -> None: + """The fact every test in this module is written around, asserted rather + than assumed. + + Uniform data is the hazard here: a wrong wiring reports the same value as a + right one, so no reading below is proved by comparing against the capture. + Were the capture ever retaken with a configured PCS, this fails first and + says so, rather than the mutation tests continuing to pass while the weaker + assertions they replace quietly became meaningful. + """ + pcs = _pcs(parent_child_tree()) + + assert pcs.enabled is False + assert pcs.active is False + assert pcs.binding_constraint == "NONE" + assert {value for name, value in _fields(pcs).items() if name.endswith("_limit_a")} == {0.0} + assert {value for name, value in _fields(pcs).items() if name.endswith("_enablement")} == {"UNCONFIGURED"} + + +def test_the_catalog_constraint_classes_are_the_ones_the_panel_declares() -> None: + """The four amps-native sources, checked against the wire rather than listed + twice. + + The capability is explicit that "the number and naming of sources is not + fixed by this spec" — a vendor may publish further triplets. So this is the + drift signal: a fifth source arriving is firmware growing a constraint class + nothing reads, and it should fail here rather than go unnoticed. + """ + declared = _declared_properties(parent_child_tree()) + triplets = { + property_id.removesuffix("-active").removesuffix("-enablement").removesuffix("-import-limit") + for property_id in declared + if property_id.endswith("-import-limit") or "-import-limit-" in property_id + } + + assert triplets == set(PCS_LIMIT_SOURCES) + + +# --------------------------------------------------------------------------- +# Every reading is proved by mutation, one property at a time +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("property_id", "attribute", "republished", "expected"), + _PANEL_READS, + ids=[property_id for property_id, _, _, _ in _PANEL_READS], +) +def test_republishing_one_property_moves_only_the_field_that_reads_it( + property_id: str, attribute: str, republished: str, expected: object +) -> None: + """The load-bearing test of the module, and the answer to the uniform capture. + + Two assertions, and the second is the one that bites. The first says the + field followed the wire. The second says *no other field did* — which is + what a field reading the neighbouring property fails, and what an assertion + against the captured zeros could never detect, since every sibling already + holds the value a wrong wiring would report. + """ + baseline = _fields(_pcs(parent_child_tree())) + + tree = _mutable_tree() + tree[PANEL][f"{NODE}/{property_id}"] = republished + after = _fields(_pcs(tree)) + + assert after[attribute] == expected + moved = {name for name, value in after.items() if value != baseline[name]} + assert moved == {attribute}, f"republishing {property_id} also moved {sorted(moved - {attribute})}" + + +def test_a_fully_configured_pcs_lands_every_value_on_its_own_field() -> None: + """All sixteen republished at once, every value distinct from its siblings'. + + The per-property test above proves each field reads its own property. This + proves the sixteen do not interfere: a mapper that assembled the dataclass + positionally, or that reused one triplet's reader for another family, passes + every single-property test and fails here. + """ + tree = _mutable_tree() + for property_id, _, republished, _ in _PANEL_READS: + tree[PANEL][f"{NODE}/{property_id}"] = republished + + after = _fields(_pcs(tree)) + + assert after == {attribute: expected for _, attribute, _, expected in _PANEL_READS} + + +def test_the_four_limits_are_four_different_readings() -> None: + """The families are distinguishable, on data where the capture makes them + identical. Four unrelated decimals, and each has to land on its own field.""" + tree = _mutable_tree() + for index, source in enumerate(PCS_LIMIT_SOURCES): + tree[PANEL][f"{NODE}/{source}-import-limit"] = str(index + 1) + + pcs = _pcs(tree) + + assert pcs.feed_import_limit_a == 1.0 + assert pcs.operator_import_limit_a == 2.0 + assert pcs.off_grid_import_limit_a == 3.0 + assert pcs.requested_import_limit_a == 4.0 + + +def test_the_effective_limit_is_not_any_of_its_inputs() -> None: + """`import-limit` is the arbitration *result*, and the catalog says so. A + mapper that took it from the FSR would be plausible and wrong, so the + republished result differs from every input.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/import-limit"] = "12.5" + for source in PCS_LIMIT_SOURCES: + tree[PANEL][f"{NODE}/{source}-import-limit"] = "99.0" + + pcs = _pcs(tree) + + assert pcs.import_limit_a == 12.5 + + +def test_enabled_and_active_are_two_different_facts() -> None: + """A configured PCS spends most of its life enabled and inactive, so the two + booleans must be readable in opposition. Both are `false` in the capture, + which is exactly the state in which crossing them is invisible.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/enabled"] = "true" + tree[PANEL][f"{NODE}/active"] = "false" + + pcs = _pcs(tree) + + assert pcs.enabled is True + assert pcs.active is False + + +def test_binding_constraint_is_kept_as_the_wire_string() -> None: + """Publishers may extend the enum through `$format`, and this property's + whole job is naming a source — so a value outside the catalog's eight must + survive rather than be normalised onto `UNKNOWN`.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/binding-constraint"] = "VENDOR_THERMAL" + + assert _pcs(tree).binding_constraint == "VENDOR_THERMAL" + + +# --------------------------------------------------------------------------- +# Absence: an unpublished property, a dropped node, no PCS at all +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("property_id", "attribute"), + [(property_id, attribute) for property_id, attribute, _, _ in _PANEL_READS], + ids=[property_id for property_id, _, _, _ in _PANEL_READS], +) +def test_a_property_the_panel_does_not_publish_is_none(property_id: str, attribute: str) -> None: + """`None`, never `0.0` and never `False`. Three of the four constraint + classes are `MAY`, so an omitted family is conformant firmware — and a limit + defaulted to zero would read as "no import permitted", the most alarming + reading the property has.""" + pcs = _pcs(_without_property(_mutable_tree(), property_id)) + + assert getattr(pcs, attribute) is None + + +def test_dropping_one_property_leaves_the_others_reading() -> None: + """Absence is per-property: a panel publishing a partial `pcs` node still + reports the part it has.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/import-limit"] = "17.5" + pcs = _pcs(_without_property(tree, "feed-import-limit")) + + assert pcs.feed_import_limit_a is None + assert pcs.import_limit_a == 17.5 + + +def test_a_panel_with_no_pcs_node_carries_no_pcs_at_all() -> None: + """The presence signal a consumer gates entity creation on. `None` rather + than an empty instance, so nothing has to be inferred from a sentinel.""" + assert _snapshot(_without_node(_mutable_tree())).pcs is None + + +def test_a_switched_off_pcs_is_still_a_pcs() -> None: + """The distinction the node-presence gate exists to keep, and the reason it + cannot be a value gate: this capture publishes zeros throughout, and a + consumer that read those as absence would delete the entities of every panel + whose PCS is merely unconfigured.""" + assert _snapshot(parent_child_tree()).pcs is not None + + +def test_a_declared_node_with_no_published_values_is_still_present() -> None: + """Mid-discovery is the normal case for a device that has announced itself + and not yet retained its topics. The node is declared, so the PCS exists and + every reading is unknown — which is not the same as no PCS.""" + tree = _mutable_tree() + for property_id in _PANEL_PROPERTIES: + del tree[PANEL][f"{NODE}/{property_id}"] + + pcs = _snapshot(tree).pcs + + assert pcs is not None + assert set(_fields(pcs).values()) == {None} + + +def test_a_limit_that_is_not_a_number_reads_as_absent() -> None: + """Same answer as not publishing, because neither is a reading.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/import-limit"] = "n/a" + + assert _pcs(tree).import_limit_a is None + + +def test_zero_amps_is_a_reading_and_not_an_absence() -> None: + """The distinction the `None` default exists to keep: the PCS is permitting + no import at all, which is a state, not a gap.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/import-limit"] = "0.0" + + assert _pcs(tree).import_limit_a == 0.0 + + +# --------------------------------------------------------------------------- +# The circuit half: participation, not arbitration +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_participation_on_its_circuits() -> None: + """Guard the premise for the circuit tests, and pin that the two circuits + they use actually disagree.""" + tree = parent_child_tree() + + assert tree[MANAGED_CIRCUIT][f"{NODE}/managed"] == "true" + assert tree[UNMANAGED_CIRCUIT][f"{NODE}/managed"] == "false" + assert tree[MANAGED_CIRCUIT][f"{NODE}/priority"] != tree[UNMANAGED_CIRCUIT][f"{NODE}/priority"] + + +def test_a_circuit_reports_its_own_participation() -> None: + """Read against the tree rather than against literals, and against two + circuits that differ, so a mapper reporting a constant fails.""" + tree = parent_child_tree() + circuits = _snapshot(tree).circuits + + assert circuits[MANAGED_CIRCUIT].pcs_managed is True + assert circuits[UNMANAGED_CIRCUIT].pcs_managed is False + assert circuits[MANAGED_CIRCUIT].pcs_priority == int(tree[MANAGED_CIRCUIT][f"{NODE}/priority"]) + assert circuits[UNMANAGED_CIRCUIT].pcs_priority == int(tree[UNMANAGED_CIRCUIT][f"{NODE}/priority"]) + + +def test_republishing_participation_moves_the_circuit_fields() -> None: + """The mutation half. The republished priority is outside the range the + capture uses on any circuit, so a field wired to another circuit's value — + or to the load-shed priority beside it — cannot report it.""" + tree = _mutable_tree() + tree[MANAGED_CIRCUIT][f"{NODE}/managed"] = "false" + tree[MANAGED_CIRCUIT][f"{NODE}/priority"] = "42" + + circuit = _snapshot(tree).circuits[MANAGED_CIRCUIT] + + assert circuit.pcs_managed is False + assert circuit.pcs_priority == 42 + + +def test_pcs_priority_is_not_the_load_shed_priority() -> None: + """Two policies on one relay, kept apart by the catalog and here. One is an + integer shed ordering under an import limit; the other is the backup tier a + user sets, and they do not even share a value space.""" + circuit = _snapshot(parent_child_tree()).circuits[MANAGED_CIRCUIT] + + assert isinstance(circuit.pcs_priority, int) + assert isinstance(circuit.priority, str) + assert circuit.priority != str(circuit.pcs_priority) + + +@pytest.mark.parametrize("property_id", ["managed", "priority"]) +def test_a_circuit_that_does_not_publish_participation_reports_none(property_id: str) -> None: + """Both are `MAY`. A circuit that has not said it is managed has not said it + is unmanaged, and priority `0` is a legal ranking — so neither may default.""" + tree = _mutable_tree() + del tree[MANAGED_CIRCUIT][f"{NODE}/{property_id}"] + + circuit = _snapshot(tree).circuits[MANAGED_CIRCUIT] + + assert getattr(circuit, f"pcs_{property_id}") is None + + +def test_a_circuit_with_no_pcs_node_participates_in_nothing() -> None: + circuit = _snapshot(_without_node(_mutable_tree(), MANAGED_CIRCUIT)).circuits[MANAGED_CIRCUIT] + + assert circuit.pcs_managed is None + assert circuit.pcs_priority is None + + +def test_a_synthesised_unmapped_position_carries_no_participation() -> None: + """Unmapped tabs are invented by the adapter, not published, so claiming a + PCS relationship for one would be a fabrication.""" + circuits = _snapshot(parent_child_tree()).circuits + unmapped = next(circuit for circuit_id, circuit in circuits.items() if circuit_id.startswith("unmapped_tab_")) + + assert unmapped.pcs_managed is None + assert unmapped.pcs_priority is None + + +# --------------------------------------------------------------------------- +# Metadata: only the result carries a row +# --------------------------------------------------------------------------- + + +def test_the_effective_limit_takes_its_unit_from_the_tree() -> None: + metadata = build_field_metadata(_devices(parent_child_tree())) + declared = _declared_properties(parent_child_tree()) + + entry = metadata["pcs.import_limit_a"] + assert entry.resolved is True + assert entry.unit == declared["import-limit"]["unit"] + assert entry.datatype == declared["import-limit"]["datatype"] + + +def test_changing_the_declared_unit_changes_the_metadata() -> None: + """The mutation proof for the metadata half: the unit comes from the panel's + own `$description`, not from the vendored catalog and not from a literal.""" + tree = _mutable_tree() + description = json.loads(tree[PANEL]["$description"]) + description["nodes"][NODE]["properties"]["import-limit"]["unit"] = "kA" + tree[PANEL]["$description"] = json.dumps(description) + + assert build_field_metadata(_devices(tree))["pcs.import_limit_a"].unit == "kA" + + +def test_the_result_properties_carry_rows_and_the_inputs_do_not() -> None: + """Deliberate, and asserted so it stays deliberate. + + The capability calls `import-limit` and `binding-constraint` "the result", + and those plus `active` are what a consumer renders as readings. The four + constraint families and `enabled` qualify that result rather than standing + alone, so a unit row for them would advertise a surface that is not there — + the same treatment the `shed-forecast` full-charge pair gets. + """ + metadata = build_field_metadata(_devices(parent_child_tree())) + pcs_rows = {path for path in metadata if path.startswith("pcs.")} + + assert pcs_rows == {"pcs.import_limit_a", "pcs.binding_constraint", "pcs.active"} + + +def test_a_declared_node_missing_a_property_is_a_gap_not_absent_hardware() -> None: + """The three-way contract: the node is here, so an omitted property is + degradation and gets an unresolved row rather than no row.""" + metadata = build_field_metadata(_devices(_without_property(_mutable_tree(), "import-limit"))) + + assert metadata["pcs.import_limit_a"] == FieldMetadata(unit=None, datatype="unknown", resolved=False) + + +def test_no_pcs_node_produces_no_rows_at_all() -> None: + """Hardware that is not there is not a defect: no entry, so a consumer reads + "nothing will populate this" rather than "this is broken".""" + metadata = build_field_metadata(_devices(_without_node(_mutable_tree()))) + + assert not [path for path in metadata if path.startswith("pcs.")] + + +def test_circuit_participation_carries_no_metadata_row() -> None: + """Read into the snapshot and rendered as attributes on the circuit's own + sensor, not as readings of their own.""" + metadata = build_field_metadata(_devices(parent_child_tree())) + + assert "circuit.pcs_managed" not in metadata + assert "circuit.pcs_priority" not in metadata From 95cd48527aae8210be32a4189c92f6abdd49d8c4 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:25:50 -0700 Subject: [PATCH 084/115] feat(schema-1): read the enclosure's link health for every DER, not just the BESS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `battery.connected` has carried the enclosure's view of the link to the BESS since v1.0 landed — the upstream lugs' `connection/fed-by-device-status`. The other half of the same capability reached nothing. A circuit that feeds a commissioned DER publishes `connection/feeds-device-status`, which is how the enclosure reports the link to a PV or a charger, and only one of a panel's three DER classes had a field for it. `SpanEvseSnapshot.connected` and `SpanPVSnapshot.connected` close that, read through `feed_connection_statuses` — the sibling of `feed_circuit_ids`, reading the other half of the same circuit-side records. `connection_status_for` is untouched: that one scans owners for a `fed-by-*` record naming a device, this one indexes every `feeds-*` record a circuit publishes, and both hand their answer to one `_connected` so the three fields cannot drift apart. **Absence is the specification's "unknown", and nothing here treats it as a fault.** The enum is `OK,LOST,DEGRADED` with no UNKNOWN member, so an unpublished property is the only way a panel can say it does not know; and `distribution-enclosure.md` states that a mixed-load or unsurveyed circuit publishes no connection record at all, which is the normal state for most of a panel's circuits — two of the reference capture's five. So the gate is the record existing, never the circuit's type: a DER no circuit claims reports `None`, as does one whose circuit publishes an id with no status. Half a record is not a record, and neither half alone can claim a device. `DEGRADED` collapses to `False`, because the question this field answers is whether the enclosure can talk to the device. **The charger's link is not the charger's session.** `evse.status` is the state the charger reports about the cable in front of it; this is the enclosure reporting whether it can reach the charger. A charger mid-session over a lost link publishes `CHARGING` and `connected=False` at once, and a test holds them independent by producing exactly that. Both fields get `_PROPERTY_FIELD_MAP` rows from `(circuit, connection, feeds-device-status)`. That is the one place a row's device type and its field path deliberately differ — v1.0 states the relationship on the circuit and the field belongs to the DER — and the one property carries two rows, because one circuit's record describes a PV and another's a charger. `feeds-device-id` still gets none: it is topology the mapper consumes into `feed_circuit_id`, `device_type` and `relative_position`, not a reading. **The capture agrees with itself, so no test rests on it.** All three published records read `OK`, which an assertion of "both chargers connected" cannot tell from a mapper returning a constant, reading the wrong circuit, or handing every DER the first record it finds. Every expectation is read out of the tree, the enum's members come from the circuit's own `$format`, and each reading is proved by republishing values that differ per DER and then swapping them — two chargers are in the capture precisely so that is falsifiable. Dropping the `None` guard fails four tests; keying the charger's lookup on the harmonised serial instead of the tree id fails eight; handing every DER the first record fails six; accepting a half-record fails two. --- CHANGELOG.md | 11 + .../src/span_panel_api_schema_1/devices.py | 65 ++- .../span_panel_api_schema_1/field_metadata.py | 18 + .../src/span_panel_api_schema_1/snapshot.py | 20 +- src/span_panel_api/models.py | 32 ++ tests/test_schema_migration_delta.py | 7 + tests/test_schema_one_connection_health.py | 371 ++++++++++++++++++ tests/test_schema_one_devices.py | 14 +- 8 files changed, 526 insertions(+), 12 deletions(-) create mode 100644 tests/test_schema_one_connection_health.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b76f855..d82cdae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- **Per-DER connection health reaches the snapshot: `SpanEvseSnapshot.connected` and `SpanPVSnapshot.connected`.** `battery.connected` has carried the enclosure's view of the link to the BESS since v1.0 landed, from the upstream lugs' + `connection/fed-by-device-status`. The other half of the same capability — a circuit's `connection/feeds-device-status`, which is how the enclosure reports the link to a PV or a charger — reached nothing, so only one of a panel's three DER classes had a + link-health field. Both new fields are `bool | None` and mirror `battery.connected` exactly, read by `build_pv` and `build_evse` through the new `feed_connection_statuses`. +- **`None` is the specification's "unknown", and it is load-bearing.** The enum is `OK,LOST,DEGRADED` with no `UNKNOWN` member, so an unpublished property is the only way a panel can say it does not know — and `distribution-enclosure.md` states that a + mixed-load or unsurveyed circuit publishes no connection record at all, which is the normal state for most of a panel's circuits. So absence is never a fault: a DER no circuit claims, or one whose circuit publishes an id without a status, reports `None` + rather than `False`. `DEGRADED` collapses to `False`, because the question this field answers is whether the enclosure can talk to the device. +- **The charger's link is not the charger's session.** `evse.status` is the OCPP-style state the charger reports about the cable in front of it; `evse.connected` is the enclosure reporting whether it can reach the charger at all. A charger mid-session over + a lost link publishes `CHARGING` and `connected=False` at once, and the two fields stay separate for the same reason `battery.connected` and `battery.communication_state` do. +- **`_PROPERTY_FIELD_MAP` rows for both**, from `(circuit, connection, feeds-device-status)` — the one place a row's device type and its field path deliberately differ, because v1.0 states the relationship on the circuit and the field belongs to the DER. + One property carries two rows, since one circuit's record describes a PV and another's a charger. Both buy the datatype the circuit's own `$description` declares plus the three-way resolution contract. + - **The BESS's own meter and link health reach the snapshot: `SpanBatterySnapshot.power_w` and `SpanBatterySnapshot.communication_state`.** The battery device has published `meter/active-power` and `status/communication-state` all along and neither reached a field, so a consumer could show the enclosure's arbitrated `power_flow_battery` and nothing the BESS itself reports. Both are `None` on a BESS that publishes no such node, and on every flat panel — the flat schema's BESS device class declares neither property, so this is new surface rather than a re-sourcing, and nothing that exists today changes. diff --git a/packages/schema-1/src/span_panel_api_schema_1/devices.py b/packages/schema-1/src/span_panel_api_schema_1/devices.py index 0146495..645f9c8 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/devices.py +++ b/packages/schema-1/src/span_panel_api_schema_1/devices.py @@ -72,6 +72,7 @@ PROP_STATUS = "status" PROP_FEEDS_DEVICE_ID = "feeds-device-id" +PROP_FEEDS_DEVICE_STATUS = "feeds-device-status" PROP_FED_BY_DEVICE_ID = "fed-by-device-id" PROP_FED_BY_DEVICE_STATUS = "fed-by-device-status" @@ -111,6 +112,54 @@ def connection_status_for(device_id: str, owners: list[DiscoveredDevice]) -> str return None +def feed_connection_statuses(circuits: list[DiscoveredDevice]) -> dict[str, str]: + """The enclosure's view of the link to each circuit-fed device, by device id. + + The other half of the record ``feed_circuit_ids`` reads, and read alongside + it for the same reason: v1.0 states the relationship on the *circuit*, so a + DER's link health is published by whichever circuit feeds it rather than by + the DER. Same fact as ``connection_status_for``, opposite direction — that + one scans owners for a ``fed-by-*`` record naming the device, this one + indexes every ``feeds-*`` record a circuit publishes. + + A circuit is absent from the result unless it publishes *both* halves. An + id with no status cannot say anything about the link, and a status with no + id names no device to say it about; the enclosure model + (``distribution-enclosure.md``) makes an unpublished property the panel's + way of saying it does not know, so absence here is what a caller turns into + `None` rather than into a fault. + + Most circuits publish neither. A mixed-load or unsurveyed circuit feeds no + commissioned DER, so it has no connection record to publish — the spec calls + that normal, which is why nothing here treats a missing record as an error. + """ + statuses: dict[str, str] = {} + for circuit in circuits: + fed = text(circuit, NODE_CONNECTION, PROP_FEEDS_DEVICE_ID) + status = text(circuit, NODE_CONNECTION, PROP_FEEDS_DEVICE_STATUS) + if fed and status: + statuses[fed] = status + return statuses + + +def _connected(status: str | None) -> bool | None: + """Collapse a ``connection`` status enum to the snapshot's boolean. + + `None` stays `None`, so "nobody has said" remains distinct from "not OK". + The enum is ``OK,LOST,DEGRADED`` with no UNKNOWN member, so absence of the + property is the only unknown the wire can express and this is the one place + that decides what it means. + + DEGRADED collapses to `False` deliberately: the question a consumer asks of + this field is "can the enclosure talk to the device", and a degraded link is + not a working one. The distinction survives where it is a device's own + report — `battery.communication_state` keeps the enum string — but here it + is the panel's view, and the panel publishes no richer field for a consumer + to fall back on. + """ + return None if status is None else status == STATUS_OK + + def _charge_positive(raw_power_w: float | None) -> float | None: """Flip the enclosure's meter frame to the snapshot's charge-positive one. @@ -147,7 +196,7 @@ def build_battery(bess: DiscoveredDevice | None, owners: list[DiscoveredDevice]) software_version=_optional(text(bess, NODE_INFO, PROP_FIRMWARE_VERSION)), nameplate_capacity_kwh=number(bess, NODE_INFO, PROP_NAMEPLATE_CAPACITY), # None when unclaimed, so "nobody has said" stays distinct from "not OK". - connected=None if status is None else status == STATUS_OK, + connected=_connected(status), power_w=_charge_positive(number(bess, NODE_METER, PROP_ACTIVE_POWER)), # The BESS's own link health, kept as the published enum string rather # than collapsed to a bool: DEGRADED is neither OK nor LOST, and a bool @@ -161,12 +210,15 @@ def build_pv( feeds: dict[str, str], upstream_lugs: DiscoveredDevice | None = None, downstream_lugs: DiscoveredDevice | None = None, + *, + feed_statuses: dict[str, str], ) -> SpanPVSnapshot: """Build the PV snapshot. An uncommissioned panel yields the empty one.""" if pv is None: return SpanPVSnapshot() return SpanPVSnapshot( + connected=_connected(feed_statuses.get(pv.device_id)), vendor_name=_optional(text(pv, NODE_INFO, PROP_VENDOR_NAME)), model=_optional(text(pv, NODE_INFO, PROP_MODEL)), software_version=_optional(text(pv, NODE_INFO, PROP_FIRMWARE_VERSION)), @@ -179,16 +231,25 @@ def build_pv( ) -def build_evse(evse: DiscoveredDevice, feeds: dict[str, str], *, node_id: str) -> SpanEvseSnapshot: +def build_evse( + evse: DiscoveredDevice, feeds: dict[str, str], *, node_id: str, feed_statuses: dict[str, str] +) -> SpanEvseSnapshot: """Build one EVSE snapshot. `node_id` is supplied rather than taken from `evse.device_id`: it feeds the integration's device-registry `identifiers`, so it has to be the harmonised key, not the v1.0 device id. See `_harmonised_evse_keys`. + + Both lookups are keyed on `evse.device_id`, the v1.0 id, and not on + `node_id`: a connection record names the device the way the tree does, and a + panel with two chargers has two records to tell apart. Keying the status on + the harmonised serial would find nothing on every panel and, worse, would + find the *wrong* charger the moment two of them harmonised alike. """ return SpanEvseSnapshot( node_id=node_id, feed_circuit_id=feeds.get(evse.device_id, ""), + connected=_connected(feed_statuses.get(evse.device_id)), status=text(evse, NODE_STATUS, PROP_STATUS, UNKNOWN), lock_state=text(evse, NODE_SWITCH, PROP_LOCK_STATE, UNKNOWN), advertised_current_a=number(evse, NODE_METER, PROP_ADVERTISED_CURRENT), diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index 31f25e1..50cf98c 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -21,6 +21,7 @@ from span_panel_api.models import FieldMetadata from span_panel_api_schema_1.const import ( NODE_BREAKER, + NODE_CONNECTION, NODE_DOOR, NODE_INFO, NODE_LOAD_SHED, @@ -110,6 +111,23 @@ (TYPE_CIRCUIT, NODE_METER, "exported-energy", "circuit.consumed_energy_wh"), (TYPE_CIRCUIT, NODE_BREAKER, "rating", "circuit.breaker_rating_a"), (TYPE_CIRCUIT, NODE_BREAKER, "poles", "circuit.is_240v"), + # --- Circuit `connection` -> the DER the circuit feeds --------------------- + # The one place a row's device type and its field path deliberately disagree. + # v1.0 states the enclosure/DER relationship on the *circuit*, so the panel's + # view of the link to a PV or a charger is published by whichever circuit + # feeds it -- `build_pv` and `build_evse` read it through + # `feed_connection_statuses`, and the field it fills belongs to the DER. + # + # Two rows for one property, because one circuit's record describes a PV and + # another's describes a charger. That is what the property *is* on this + # device class; which DER a given instance names is a value, and a metadata + # row describes neither values nor instances. + # + # `feeds-device-id` carries no row on purpose: it is topology the mapper + # consumes into `feed_circuit_id`, `device_type` and `relative_position`, and + # a unit for a device id would describe a reading nothing renders. + (TYPE_CIRCUIT, NODE_CONNECTION, "feeds-device-status", "evse.connected"), + (TYPE_CIRCUIT, NODE_CONNECTION, "feeds-device-status", "pv.connected"), # --- BESS ---------------------------------------------------------------- (TYPE_BESS, NODE_SOC, "soc", "battery.soe_percentage"), (TYPE_BESS, NODE_SOC, "soe", "battery.soe_kwh"), diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index 7113609..9eeb3fd 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -25,7 +25,14 @@ TYPE_MID, TYPE_PV, ) -from span_panel_api_schema_1.devices import build_battery, build_evse, build_mid, build_pv, feed_circuit_ids +from span_panel_api_schema_1.devices import ( + build_battery, + build_evse, + build_mid, + build_pv, + feed_circuit_ids, + feed_connection_statuses, +) from span_panel_api_schema_1.panel import ( PanelFields, build_pcs, @@ -98,6 +105,10 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re fields = PanelFields(panel=panel, upstream_lugs=upstream, downstream_lugs=downstream, mid=roles.mid) feeds = feed_circuit_ids(roles.circuits) + # The other half of the same circuit-side records: which DER each circuit + # feeds, and what the enclosure says about the link to it. Read once here + # and handed to whichever DER it names, exactly as `feeds` is. + feed_statuses = feed_connection_statuses(roles.circuits) # A DER's device type decides how its feeding circuit is labelled, so the # circuit inherits it — matching the flat adapter, where the same circuit # reports device_type "pv" rather than "circuit". @@ -184,13 +195,16 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re downstream_l2_current_a=fields.downstream_l2_current_a, circuits=circuits, battery=build_battery(roles.bess, owners), - pv=build_pv(roles.pv, feeds, upstream, downstream), + pv=build_pv(roles.pv, feeds, upstream, downstream, feed_statuses=feed_statuses), mid=build_mid(roles.mid, device_names), # Gated on the node being declared, not on any value: every limit this # capability publishes is legally `0.0`, so there is no reading that can # distinguish a switched-off PCS from an absent one. See `build_pcs`. pcs=build_pcs(panel), - evse={key: build_evse(device, feeds, node_id=key) for device, key in _harmonised_evse_keys(roles.evse).items()}, + evse={ + key: build_evse(device, feeds, node_id=key, feed_statuses=feed_statuses) + for device, key in _harmonised_evse_keys(roles.evse).items() + }, ) diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index df758cf..0d7e675 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -80,6 +80,23 @@ class SpanPVSnapshot: where it is the panel's own and predates the sub-device types. """ + connected: bool | None = None + """The enclosure's view of the link to this PV, v1.0 only. + + The same fact `SpanBatterySnapshot.connected` carries and read the same way — + from the enclosure-side owner's `connection` record, never from anything the + inverter says about itself. Only the half of the record differs: a BESS is + named by the upstream lugs' `fed-by-device-*`, a circuit-fed DER by its + circuit's `feeds-device-*`. + + `None` means no owner has claimed this device, or the claiming owner + published no status — which is the specification's own "unknown" signal + (`capabilities/connection.md`: an unpublished property *is* how a panel says + it does not know) and is deliberately distinct from `False`. The enum has + three members, `OK,LOST,DEGRADED`, and no UNKNOWN, so absence is the only + way to say it. + """ + @dataclass(frozen=True, slots=True) class SpanMidSnapshot: @@ -272,6 +289,21 @@ class SpanEvseSnapshot: serial_number: str | None = None software_version: str | None = None + connected: bool | None = None + """The enclosure's view of the link to this charger, v1.0 only. + + Documented on `SpanPVSnapshot.connected`, which carries the identical fact + for the other circuit-fed DER class. + + **Not `status`.** That is the OCPP-style session state — whether a vehicle is + plugged in and what it is doing — reported by the charger about the cable in + front of it. This is the enclosure reporting whether it can talk to the + charger at all. A charger with a car plugged in and a dead link publishes + `status="CHARGING"` and `connected=False` at the same time, and a consumer + that renders them as one entity is answering the wrong question in half the + cases. + """ + @dataclass(frozen=True, slots=True) class SpanBatterySnapshot: diff --git a/tests/test_schema_migration_delta.py b/tests/test_schema_migration_delta.py index 675504b..eddfa9d 100644 --- a/tests/test_schema_migration_delta.py +++ b/tests/test_schema_migration_delta.py @@ -180,6 +180,13 @@ "panel's view rather than the device's. Two views of one link, and v1.0 is the " "first schema to publish the second" ), + "pv.connected": ( + "the enclosure's view of the link to the PV, from the feeding circuit's " + "`connection/feeds-device-status`. Flat's `energy.ebus.device.pv` type declares " + "no link property at all -- flat publishes `connected` on the BESS and nowhere " + "else -- so nothing can orphan and no entity changes meaning. v1.0 is the first " + "schema in which the enclosure says anything about the PV link" + ), } """Additions with no flat property to have been re-sourced from. diff --git a/tests/test_schema_one_connection_health.py b/tests/test_schema_one_connection_health.py new file mode 100644 index 0000000..e2a9d94 --- /dev/null +++ b/tests/test_schema_one_connection_health.py @@ -0,0 +1,371 @@ +"""The enclosure's view of the link to each circuit-fed DER. + +`connection` 0.1 states the enclosure/DER relationship on the **circuit**, not +on the DER: a circuit that feeds a commissioned device publishes +`feeds-device-id` naming it and `feeds-device-status` saying how the link is. +So a PV's or a charger's link health arrives on a different device from the one +it describes, and the mapper's job is to put it back where it belongs. + +**Three things make that easy to get wrong, and every test here is aimed at one +of them.** + +*Absence is a value.* Two of the capture's five circuits publish no connection +record at all — the spec calls that normal for a mixed-load or unsurveyed +circuit — and the enum firmware does publish is `OK,LOST,DEGRADED`, with no +UNKNOWN member. So "nobody has said" can only be expressed by the property not +being there, and it has to stay distinct from "the link is down". + +*The capture agrees with itself.* All three published records read `OK`, which +means an assertion that both chargers are connected is satisfied by a mapper +that returns a constant, one that reads the wrong circuit, and one that gives +every DER the first record it finds. Nothing below rests on the captured values: +each is read out of the tree, and every reading is proved by republishing values +that differ per DER. + +*Two chargers.* The capture has two, fed by two circuits, so the wiring is +falsifiable — republish differing statuses and each charger has to report its +own. +""" + +from __future__ import annotations + +import json + +import pytest + +from span_panel_api.models import SpanEvseSnapshot, SpanPanelSnapshot +from span_panel_api_schema_1.const import NODE_CONNECTION +from span_panel_api_schema_1.devices import ( + PROP_FEEDS_DEVICE_ID, + PROP_FEEDS_DEVICE_STATUS, + STATUS_OK, + feed_connection_statuses, +) +from span_panel_api_schema_1.field_metadata import build_field_metadata +from span_panel_api_schema_1.reference_payloads import ( + RetainedTopicTree, + device_from_topics, + parent_child_tree, +) +from span_panel_api_schema_1.snapshot import build_snapshot + +PANEL = "example-40t-001" + +FEEDS_ID_TOPIC = f"{NODE_CONNECTION}/{PROP_FEEDS_DEVICE_ID}" +FEEDS_STATUS_TOPIC = f"{NODE_CONNECTION}/{PROP_FEEDS_DEVICE_STATUS}" + +# The DER device ids the capture commissions. Named rather than derived so a +# capture that stopped carrying one fails saying so, instead of quietly +# reducing every test below to a smaller panel. +PV = "pv" +EVSE = "evse" +EVSE_2 = "evse-2" + + +def _mutable_tree() -> dict[str, dict[str, str]]: + return {device_id: dict(topics) for device_id, topics in parent_child_tree().items()} + + +def _snapshot(tree: RetainedTopicTree) -> SpanPanelSnapshot: + panel = device_from_topics(PANEL, tree[PANEL]) + children = [device_from_topics(device_id, topics) for device_id, topics in tree.items() if device_id != PANEL] + return build_snapshot(panel, children) + + +def _feeding_circuit(tree: RetainedTopicTree, device_id: str) -> str: + """The circuit the capture says feeds `device_id`, or fail saying none does.""" + feeders = [circuit_id for circuit_id, topics in tree.items() if topics.get(FEEDS_ID_TOPIC) == device_id] + assert len(feeders) == 1, f"the capture has {len(feeders)} circuits feeding {device_id}, expected 1" + return feeders[0] + + +def _evse_fed_by(snapshot: SpanPanelSnapshot, circuit_id: str) -> SpanEvseSnapshot: + """The charger the snapshot says that circuit feeds. + + Looked up by feed rather than by snapshot key: the key is the harmonised + serial, and a test that hardcoded one would still pass if the mapper + attached every record to the same charger. + """ + matches = [evse for evse in snapshot.evse.values() if evse.feed_circuit_id == circuit_id] + assert len(matches) == 1, f"{len(matches)} chargers report circuit {circuit_id} as their feed" + return matches[0] + + +def _status_options() -> list[str]: + """The enum as the circuit's own `$description` declares it. + + Read from the wire rather than written here, because the property's legal + values are the panel's claim and not this test's. It is also the assertion + that there is no UNKNOWN member — which is *why* absence has to carry that + meaning instead. + """ + tree = parent_child_tree() + circuit = _feeding_circuit(tree, PV) + description = json.loads(tree[circuit]["$description"]) + node = description["nodes"][NODE_CONNECTION]["properties"][PROP_FEEDS_DEVICE_STATUS] + assert node["datatype"] == "enum" + return str(node["format"]).split(",") + + +def _not_ok() -> list[str]: + """Every declared status that is not `OK`, in declaration order.""" + return [option for option in _status_options() if option != STATUS_OK] + + +def _with_status(tree: dict[str, dict[str, str]], device_id: str, status: str) -> None: + """Republish the link status of whichever circuit feeds `device_id`.""" + tree[_feeding_circuit(tree, device_id)][FEEDS_STATUS_TOPIC] = status + + +# --------------------------------------------------------------------------- +# What the capture declares and publishes +# --------------------------------------------------------------------------- + + +def test_the_status_enum_has_no_unknown_member() -> None: + """The premise every absence test below rests on. + + If firmware ever gains an UNKNOWN member, "unpublished means unknown" stops + being the only way to say it and this design should be revisited — so the + premise is asserted rather than assumed. + """ + options = _status_options() + + assert STATUS_OK in options + assert "UNKNOWN" not in options + assert _not_ok(), "the enum declares nothing but OK, so no test here can observe a bad link" + + +def test_only_the_circuits_feeding_a_der_publish_a_connection_record() -> None: + """The negative case is in the capture, not manufactured by a test. + + Five circuits, three of which feed a commissioned DER. The other two feed + ordinary loads and publish neither half of the record — which + `distribution-enclosure.md` describes as the normal state for a mixed-load + circuit, and which is exactly the shape a mapper must not read as a fault. + """ + tree = parent_child_tree() + circuits = { + device_id for device_id, topics in tree.items() if json.loads(topics["$description"])["type"].endswith(".circuit") + } + publishing = {device_id for device_id in circuits if FEEDS_ID_TOPIC in tree[device_id]} + + assert publishing == {_feeding_circuit(tree, der) for der in (PV, EVSE, EVSE_2)} + silent = circuits - publishing + assert silent, "the capture has no DER-less circuit, so the absence case is untested" + for device_id in silent: + declared = json.loads(tree[device_id]["$description"])["nodes"] + assert NODE_CONNECTION in declared, ( + f"{device_id} does not even declare the node, so its silence proves nothing " + "about a circuit that declares the record and publishes none of it" + ) + assert not [topic for topic in tree[device_id] if topic.startswith(f"{NODE_CONNECTION}/")] + + +# --------------------------------------------------------------------------- +# The reading +# --------------------------------------------------------------------------- + + +def test_each_der_takes_the_link_health_of_the_circuit_that_feeds_it() -> None: + """Every expectation computed from the capture, none of them written here.""" + tree = parent_child_tree() + snapshot = _snapshot(tree) + + for der in (PV, EVSE, EVSE_2): + circuit = _feeding_circuit(tree, der) + published = tree[circuit][FEEDS_STATUS_TOPIC] + expected = published == STATUS_OK + reported = snapshot.pv.connected if der == PV else _evse_fed_by(snapshot, circuit).connected + assert reported is expected, f"{der}: circuit {circuit} publishes {published!r}" + + +def test_two_chargers_do_not_share_one_link() -> None: + """The cross-wiring case, and the reason two EVSE are worth the fixture. + + Both chargers read `OK` in the capture, so the baseline assertion above is + satisfied by a mapper that hands every charger the first record it finds. + Here they are republished differing, then swapped: a mapper keyed on the + wrong thing gets one of the two arrangements right by luck and never both. + """ + down, degraded = _not_ok()[0], _not_ok()[-1] + + for first, second in ((down, STATUS_OK), (STATUS_OK, down), (degraded, STATUS_OK)): + tree = _mutable_tree() + _with_status(tree, EVSE, first) + _with_status(tree, EVSE_2, second) + snapshot = _snapshot(tree) + + assert _evse_fed_by(snapshot, _feeding_circuit(tree, EVSE)).connected is (first == STATUS_OK) + assert _evse_fed_by(snapshot, _feeding_circuit(tree, EVSE_2)).connected is (second == STATUS_OK) + + +def test_the_pv_link_is_not_the_chargers_link() -> None: + """The third DER, held apart from the two chargers the same way.""" + down = _not_ok()[0] + tree = _mutable_tree() + _with_status(tree, PV, down) + + snapshot = _snapshot(tree) + + assert snapshot.pv.connected is False + for evse in snapshot.evse.values(): + assert evse.connected is True + + +@pytest.mark.parametrize("status", _not_ok()) +def test_every_status_that_is_not_ok_reads_as_a_broken_link(status: str) -> None: + """DEGRADED is not OK, and the boolean has to say so. + + Both non-OK members are exercised, so a mapper testing `!= "LOST"` — which + passes the LOST case and calls a degraded link healthy — fails here. + """ + tree = _mutable_tree() + _with_status(tree, PV, status) + _with_status(tree, EVSE, status) + + snapshot = _snapshot(tree) + + assert snapshot.pv.connected is False + assert _evse_fed_by(snapshot, _feeding_circuit(tree, EVSE)).connected is False + + +# --------------------------------------------------------------------------- +# Absence, in each of its three shapes +# --------------------------------------------------------------------------- + + +def test_a_circuit_that_stops_publishing_the_status_reports_unknown_not_disconnected() -> None: + """Retained topics vanish; the reading has to vanish with them. + + `None` rather than `False`, because the enum cannot say "unknown" and a + `False` here would tell a user their charger is unreachable on the strength + of the panel having said nothing at all. + """ + tree = _mutable_tree() + del tree[_feeding_circuit(tree, PV)][FEEDS_STATUS_TOPIC] + + snapshot = _snapshot(tree) + + assert snapshot.pv.connected is None + for evse in snapshot.evse.values(): + assert evse.connected is True, "removing one circuit's status changed another DER's reading" + + +def test_a_der_no_circuit_claims_is_unknown_rather_than_disconnected() -> None: + """The unclaimed case: a status with no id names nobody. + + Half a record is not a record. A circuit still publishing `OK` while no + longer naming the device it feeds says nothing about that device, and the + id is what the mapper matches on. + """ + tree = _mutable_tree() + circuit = _feeding_circuit(tree, PV) + del tree[circuit][FEEDS_ID_TOPIC] + assert tree[circuit][FEEDS_STATUS_TOPIC] == STATUS_OK + + assert _snapshot(tree).pv.connected is None + + +def test_a_circuit_publishing_neither_half_leaves_its_der_unknown() -> None: + """Both halves gone, which is what a decommissioned DER's circuit looks like.""" + tree = _mutable_tree() + circuit = _feeding_circuit(tree, EVSE) + del tree[circuit][FEEDS_ID_TOPIC] + del tree[circuit][FEEDS_STATUS_TOPIC] + + snapshot = _snapshot(tree) + + unclaimed = [evse for evse in snapshot.evse.values() if evse.connected is None] + assert len(unclaimed) == 1 + assert unclaimed[0].feed_circuit_id == "", "a charger with no feeding circuit still reports one" + + +def test_the_status_map_ignores_a_circuit_that_publishes_only_one_half() -> None: + """The rule stated once, at the function that enforces it.""" + tree = _mutable_tree() + solar, garage = _feeding_circuit(tree, PV), _feeding_circuit(tree, EVSE) + del tree[solar][FEEDS_STATUS_TOPIC] + del tree[garage][FEEDS_ID_TOPIC] + + statuses = feed_connection_statuses([device_from_topics(device_id, tree[device_id]) for device_id in (solar, garage)]) + + assert statuses == {} + + +# --------------------------------------------------------------------------- +# The facts this must not be confused with +# --------------------------------------------------------------------------- + + +def test_the_charger_link_is_independent_of_whether_a_car_is_plugged_in() -> None: + """`evse.status` is the session; `evse.connected` is the link. + + A charger reporting CHARGING over a link the enclosure has lost is the case + that tells the two apart, and it is a state real hardware reaches — the + charger keeps charging while the panel stops hearing from it. + """ + down = _not_ok()[0] + tree = _mutable_tree() + circuit = _feeding_circuit(tree, EVSE) + _with_status(tree, EVSE, down) + + evse = _evse_fed_by(_snapshot(tree), circuit) + + assert evse.connected is False + assert evse.status == parent_child_tree()[EVSE]["status/status"] + + +def test_the_battery_link_still_comes_from_the_lugs_not_from_a_circuit() -> None: + """The two halves of `connection` stay on their own devices. + + `battery.connected` is the upstream lugs' `fed-by-*` view. Breaking every + circuit-side record must not touch it, or the new route has quietly taken + over a field that was already right. + """ + down = _not_ok()[0] + tree = _mutable_tree() + for der in (PV, EVSE, EVSE_2): + _with_status(tree, der, down) + + assert _snapshot(tree).battery.connected is True + + +# --------------------------------------------------------------------------- +# Metadata +# --------------------------------------------------------------------------- + + +def test_both_der_link_fields_take_their_type_from_the_circuit_description() -> None: + """One property, two field paths, because one circuit's record is a PV's and + another's is a charger's.""" + tree = parent_child_tree() + metadata = build_field_metadata([device_from_topics(device_id, topics) for device_id, topics in tree.items()]) + + for path in ("pv.connected", "evse.connected"): + assert metadata[path].datatype == "enum" + assert metadata[path].unit is None + assert metadata[path].resolved is True + + +def test_a_circuit_declaring_the_node_without_the_property_reports_a_gap() -> None: + """The three-way contract, on the property that now carries a row. + + Node present and property missing is a declared gap, which is what makes + the difference between hardware that lacks the capability and firmware that + dropped a property visible to a consumer. + """ + tree = _mutable_tree() + devices = [] + for device_id, topics in tree.items(): + description = json.loads(topics["$description"]) + properties = description.get("nodes", {}).get(NODE_CONNECTION, {}).get("properties") + if properties is not None: + properties.pop(PROP_FEEDS_DEVICE_STATUS, None) + topics["$description"] = json.dumps(description) + devices.append(device_from_topics(device_id, topics)) + + metadata = build_field_metadata(devices) + + for path in ("pv.connected", "evse.connected"): + assert metadata[path].resolved is False diff --git a/tests/test_schema_one_devices.py b/tests/test_schema_one_devices.py index 8c76d9e..1ed13b1 100644 --- a/tests/test_schema_one_devices.py +++ b/tests/test_schema_one_devices.py @@ -323,7 +323,7 @@ def test_communication_state_and_connected_are_independent() -> None: def test_pv_metadata_and_feed() -> None: - pv = build_pv(_device("pv"), feed_circuit_ids(_circuits())) + pv = build_pv(_device("pv"), feed_circuit_ids(_circuits()), feed_statuses={}) assert pv.vendor_name == "Enphase" assert pv.model == "IQ8PLUS-72-2-US" @@ -335,11 +335,11 @@ def test_pv_relative_position_is_not_guessed() -> None: """Retired in v1.0 and only "derivable from connection records (when present)". The integration gates control entities on it, so a wrong value creates or removes a control.""" - assert build_pv(_device("pv"), {}).relative_position is None + assert build_pv(_device("pv"), {}, feed_statuses={}).relative_position is None def test_no_pv_yields_the_empty_snapshot() -> None: - assert build_pv(None, {}).vendor_name is None + assert build_pv(None, {}, feed_statuses={}).vendor_name is None # --------------------------------------------------------------------------- @@ -348,7 +348,7 @@ def test_no_pv_yields_the_empty_snapshot() -> None: def test_evse_state_and_metadata() -> None: - evse = build_evse(_device("evse"), {}, node_id="evse") + evse = build_evse(_device("evse"), {}, node_id="evse", feed_statuses={}) assert evse.node_id == "evse" assert evse.status == "CHARGING" @@ -363,7 +363,7 @@ def test_evse_state_and_metadata() -> None: def test_evse_without_a_feeding_circuit_reports_empty_not_none() -> None: """`feed_circuit_id` is non-optional on the dataclass, so an unclaimed EVSE gets the empty string rather than breaking construction.""" - assert build_evse(_device("evse"), {}, node_id="evse").feed_circuit_id == "" + assert build_evse(_device("evse"), {}, node_id="evse", feed_statuses={}).feed_circuit_id == "" def test_the_mid_is_surfaced_as_its_own_device() -> None: @@ -434,7 +434,7 @@ def test_the_pv_carries_its_firmware_version() -> None: device = _device("pv") device.update_property("info", "firmware-version", "sim-pv/v0.1.0") - assert build_pv(device, {}).software_version == "sim-pv/v0.1.0" + assert build_pv(device, {}, feed_statuses={}).software_version == "sim-pv/v0.1.0" def test_a_device_publishing_no_revision_reports_none_rather_than_empty_string() -> None: @@ -449,4 +449,4 @@ def test_a_device_publishing_no_revision_reports_none_rather_than_empty_string() assert mid is not None assert mid.software_version is None assert mid.hardware_version is None - assert build_pv(_device("pv"), {}).software_version is None + assert build_pv(_device("pv"), {}, feed_statuses={}).software_version is None From 7711071e1ad7c9a894f2eb1b12ab77875886356d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:11:27 -0700 Subject: [PATCH 085/115] feat(schema-1): read the identity the panel publishes, starting with the SSID it lost `status/wifi-ssid` is a flat -> v1.0 regression, not a new feature. Flat maps `core/wifi-ssid` to `panel.wifi_ssid`, the integration surfaces it as an attribute, and schema_1 initialised the field to `None` and mapped nothing to it -- so a user upgrading lost an attribute while every check agreed. Wired the read and the metadata row; both adapters produce the path now, which is what makes it a plain declaration on the consumer side rather than an exemption. Also read, for the enclosure's device card and for the shed state beside it: - `info/{vendor-name,model,hardware-version}` -> `panel.{vendor_name,model, hardware_version}`. `None` when unpublished, never a default, because the consumer owns the text it has shown since before these were readable. - `shed/policy` -> the raw document plus its algorithm and the two SoC thresholds. Parsed defensively at every step: the `$format` schema is versioned in its own `$id`, so an algorithm this reader does not know keeps its name, yields no thresholds, and leaves the document intact for a consumer to show. A malformed one lands on the same answer as absence rather than taking a snapshot build down. - `evse/info/part-number` gains the schema_1 row flat has always had, which is what lets the consumer promote it out of one-adapter exemption. Refresh the reference tree against panelbench rather than recapturing it. The artifact is a trimmed, renamed capture shipped as package data, and it had drifted eight identity properties behind the producer: MID `info/*`, BESS `info/{part-number,serial-number,firmware-version}`, PV `info/firmware-version` and the panel's SSID were all published and absent here. Values ported in, `example-*` ids and the 5-circuit trim and every meter value kept. Four tests had been injecting those values by hand for exactly that reason, which reads as coverage and is not -- injecting a value asks whether the mapper can read a property, never whether the panel sends one. They read the capture now, and `test_reference_tree_values` pins what the capture leaves unvalued against panelbench's own baseline so the drift cannot recur unnoticed. The comparison is at device-type granularity, which the rename forces and which loses nothing: reduced the same way, that baseline is exactly the declared-but-unvalued set of panelbench's own committed wire capture. --- .../src/span_panel_api_schema_1/const.py | 15 ++ .../src/span_panel_api_schema_1/devices.py | 15 +- .../span_panel_api_schema_1/field_metadata.py | 9 ++ .../src/span_panel_api_schema_1/panel.py | 104 +++++++++++- .../reference_payloads/parent_child_tree.json | 11 +- .../src/span_panel_api_schema_1/snapshot.py | 7 + src/span_panel_api/models.py | 54 ++++++- .../fixtures/panelbench_unvalued_by_both.json | 125 ++++++++++++++ tests/test_reference_tree_values.py | 140 ++++++++++++++++ tests/test_schema_one_conformance.py | 6 + tests/test_schema_one_devices.py | 71 +++++--- tests/test_schema_one_panel.py | 152 ++++++++++++++++++ 12 files changed, 677 insertions(+), 32 deletions(-) create mode 100644 tests/fixtures/panelbench_unvalued_by_both.json create mode 100644 tests/test_reference_tree_values.py diff --git a/packages/schema-1/src/span_panel_api_schema_1/const.py b/packages/schema-1/src/span_panel_api_schema_1/const.py index 787eef9..186b602 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/const.py +++ b/packages/schema-1/src/span_panel_api_schema_1/const.py @@ -61,6 +61,14 @@ # shed node PROP_ASSERTED_ISLANDING_STATE = "asserted-islanding-state" +# The shed algorithm and its parameters, as a `json` property whose Homie +# `$format` is the JSON Schema the document conforms to. The schema is versioned +# in its own `$id` (`soc-priority.v1`), which is what lets a publisher ship a +# different algorithm without breaking a reader that pins this one: the document +# names the algorithm it used, so an unrecognised one degrades to the raw string +# rather than to a misread threshold. +PROP_POLICY = "policy" +SHED_POLICY_SOC_PRIORITY_V1 = "soc-priority.v1" # pcs node. `energy.ebus.capability.pcs` 0.3 publishes two disjoint property # sets under one node type: the enclosure runs the arbitration and publishes the @@ -111,6 +119,11 @@ PROP_CLOUD_CONNECTION = "cloud-connection" PROP_ETHERNET = "ethernet" PROP_WIFI = "wifi" +# The network the panel is joined to, not whether the radio is up -- `wifi` is +# that. Flat published the pair as `core/wifi` and `core/wifi-ssid` and the +# integration surfaces the SSID as an attribute today, so a v1.0 panel that did +# not read this one lost an attribute on upgrade. +PROP_WIFI_SSID = "wifi-ssid" # `energy.ebus.capability.status` 0.1: the publisher's view of its own link to # the device it represents (proxy) or to its backhaul (native). Enum # OK/DEGRADED/LOST/UNKNOWN. Orthogonal to whether the eBus publisher is reporting @@ -125,6 +138,8 @@ CLOUD_CONNECTED = "CONNECTED" PROP_MODEL = "model" +PROP_HARDWARE_VERSION = "hardware-version" +PROP_VENDOR_NAME = "vendor-name" # Topic root. Children are peers of the panel in the topic tree rather than # nodes beneath it, so a subscription covering the tree spans the domain. diff --git a/packages/schema-1/src/span_panel_api_schema_1/devices.py b/packages/schema-1/src/span_panel_api_schema_1/devices.py index 645f9c8..7559500 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/devices.py +++ b/packages/schema-1/src/span_panel_api_schema_1/devices.py @@ -46,6 +46,11 @@ NODE_SWITCH, PROP_ACTIVE_POWER, PROP_COMMUNICATION_STATE, + PROP_FIRMWARE_VERSION, + PROP_HARDWARE_VERSION, + PROP_MODEL, + PROP_SERIAL_NUMBER, + PROP_VENDOR_NAME, UNKNOWN, ) from span_panel_api_schema_1.panel import number, resolve_grid_forming_device_name, text @@ -55,14 +60,14 @@ from ebus_sdk.homie import DiscoveredDevice -PROP_FIRMWARE_VERSION = "firmware-version" -PROP_HARDWARE_VERSION = "hardware-version" -PROP_MODEL = "model" +# The `info` properties only a sub-device carries. The five the enclosure +# publishes too -- vendor name, model, serial, firmware and hardware revision -- +# are imported from `const` above rather than restated here: they name one wire +# property each, and two spellings of one property is how a rename reaches one +# reader and not the other. PROP_NAMEPLATE_CAPACITY = "nameplate-capacity" PROP_NOMINAL_POWER = "nominal-power" PROP_PART_NUMBER = "part-number" -PROP_SERIAL_NUMBER = "serial-number" -PROP_VENDOR_NAME = "vendor-name" PROP_SOC = "soc" PROP_SOE = "soe" diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index 50cf98c..a1a6f5c 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -62,6 +62,11 @@ (TYPE_PANEL, NODE_STATUS, "relay", "panel.main_relay_state"), (TYPE_PANEL, NODE_STATUS, "ethernet", "panel.eth0_link"), (TYPE_PANEL, NODE_STATUS, "wifi", "panel.wlan_link"), + # The SSID, not the link. Flat carries the same row (`core/wifi-ssid`), which + # is what makes this a plain both-adapters declaration on the consumer side + # rather than a schema-conditional one -- and what makes its absence here a + # regression rather than a new feature. + (TYPE_PANEL, NODE_STATUS, "wifi-ssid", "panel.wifi_ssid"), (TYPE_PANEL, NODE_STATUS, "cloud-connection", "panel.vendor_cloud"), (TYPE_PANEL, NODE_METER, "voltage-a", "panel.l1_voltage"), (TYPE_PANEL, NODE_METER, "voltage-b", "panel.l2_voltage"), @@ -152,6 +157,10 @@ (TYPE_EVSE, NODE_STATUS, "status", "evse.status"), (TYPE_EVSE, NODE_SWITCH, "lock-state", "evse.lock_state"), (TYPE_EVSE, NODE_METER, "advertised-current", "evse.advertised_current_a"), + # The charger's SKU. Flat maps `evse/part-number` to the same field, so this + # row is what lifts `evse.part_number` out of one-adapter exemption and into + # a declaration the producible gate covers on both. + (TYPE_EVSE, NODE_INFO, "part-number", "evse.part_number"), ) diff --git a/packages/schema-1/src/span_panel_api_schema_1/panel.py b/packages/schema-1/src/span_panel_api_schema_1/panel.py index f937b22..bdf9a50 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/panel.py +++ b/packages/schema-1/src/span_panel_api_schema_1/panel.py @@ -22,6 +22,7 @@ from __future__ import annotations +import json import logging from typing import TYPE_CHECKING, NamedTuple @@ -57,18 +58,23 @@ PROP_FULL_CHARGE_TIME_TO_PRIORITY_SHED, PROP_FULL_CHARGE_TOTAL_TIME_REMAINING, PROP_GRID_FORMING_ENTITY, + PROP_HARDWARE_VERSION, PROP_IMPORT_LIMIT, PROP_IMPORTED_ENERGY, PROP_MODEL, + PROP_POLICY, PROP_RATING, PROP_RELAY, PROP_SERIAL_NUMBER, PROP_STATE, PROP_TIME_TO_PRIORITY_SHED, PROP_TOTAL_TIME_REMAINING, + PROP_VENDOR_NAME, PROP_VOLTAGE_A, PROP_VOLTAGE_B, PROP_WIFI, + PROP_WIFI_SSID, + SHED_POLICY_SOC_PRIORITY_V1, TYPE_BESS, TYPE_PV, UNKNOWN, @@ -289,6 +295,82 @@ def find_lugs(devices: list[DiscoveredDevice], upstream: bool) -> DiscoveredDevi return None +class _ShedPolicy(NamedTuple): + """What `shed/policy` says, as far as this reader understands it. + + Three fields rather than a parsed document, because a consumer renders three + values beside the shed state: which algorithm is in force, and the two SoC + thresholds that make its behaviour predictable. + """ + + algorithm: str | None + soc_threshold_shed_percent: int | None + soc_threshold_release_percent: int | None + + +_NO_SHED_POLICY = _ShedPolicy(None, None, None) + + +def _shed_policy(raw: str | None) -> _ShedPolicy: + """Parse `shed/policy`, degrading rather than raising at every step. + + The property is a `json` document whose Homie `$format` is the JSON Schema + it conforms to, and that schema is versioned in its own `$id` + (`soc-priority.v1`). Versioning the document rather than the property is the + publisher's way of saying a different algorithm may arrive, so a reader that + assumed this one would misreport the day one did. + + Hence the shape here: the algorithm name is taken from whatever parses, and + the two thresholds only from a document that says it is `soc-priority.v1`. + An unrecognised algorithm keeps its name and yields no thresholds, and the + raw string is retained beside this by the caller -- a consumer can still show + what the panel said, which is strictly more than an exception leaves it. + + Every failure lands on the same answer as "not published", because to a + consumer they are the same event: there is nothing here it can render. + """ + if not raw: + return _NO_SHED_POLICY + try: + document = json.loads(raw) + except ValueError: + _LOGGER.debug("shed/policy is not JSON, keeping the raw value: %r", raw) + return _NO_SHED_POLICY + if not isinstance(document, dict): + return _NO_SHED_POLICY + + algorithm = document.get("algorithm") + algorithm = algorithm if isinstance(algorithm, str) and algorithm else None + if algorithm != SHED_POLICY_SOC_PRIORITY_V1: + # A named algorithm nothing here knows how to read is still worth + # naming: it tells a consumer why the thresholds are absent. + return _ShedPolicy(algorithm, None, None) + + parameters = document.get("parameters") + if not isinstance(parameters, dict): + return _ShedPolicy(algorithm, None, None) + return _ShedPolicy( + algorithm, + _percent(parameters.get("soc-threshold-shed")), + _percent(parameters.get("soc-threshold-release")), + ) + + +def _percent(value: object) -> int | None: + """A declared-`integer` SoC threshold, or `None` for anything that is not one. + + `bool` is excluded explicitly: it is an `int` in Python, and a policy + document carrying `true` would otherwise read as a 1% threshold. + """ + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + return None + + class PanelFields: """Panel-level values gathered from the tree, ready for the snapshot. @@ -306,6 +388,13 @@ def __init__( ) -> None: self.serial_number = text(panel, NODE_INFO, PROP_SERIAL_NUMBER, panel.device_id) self.firmware_version = text(panel, NODE_INFO, PROP_FIRMWARE_VERSION) + # The enclosure's own build identity, for the device card a consumer + # renders. `None` rather than a default when the panel does not publish + # one: the consumer owns the fallback text it has always shown, and a + # default invented here would replace it with a different invention. + self.vendor_name = text(panel, NODE_INFO, PROP_VENDOR_NAME) or None + self.model = text(panel, NODE_INFO, PROP_MODEL) or None + self.hardware_version = text(panel, NODE_INFO, PROP_HARDWARE_VERSION) or None self.main_relay_state = text(panel, NODE_STATUS, PROP_RELAY, UNKNOWN) self.door_state = text(panel, NODE_DOOR, PROP_STATE, UNKNOWN) @@ -367,8 +456,19 @@ def __init__( # Kept as an attribute only so nothing that reads it breaks; the snapshot # takes the resolver's answer. self.grid_islandable: bool | None = None - # Not published by v1.0 firmware. - self.wifi_ssid: str | None = None + # `status/wifi-ssid`, the same value flat published as `core/wifi-ssid`. + # Read here rather than left `None` because the integration surfaces it + # as an attribute today: a v1.0 panel that did not read it lost that + # attribute on upgrade, silently, while every conformance check agreed + # nothing was wrong. + self.wifi_ssid = text(panel, NODE_STATUS, PROP_WIFI_SSID) or None + + # `shed/policy` -- the algorithm the panel sheds by, and its parameters. + self.shed_policy = text(panel, NODE_SHED, PROP_POLICY) or None + policy = _shed_policy(self.shed_policy) + self.shed_policy_algorithm = policy.algorithm + self.shed_soc_threshold_shed_percent = policy.soc_threshold_shed_percent + self.shed_soc_threshold_release_percent = policy.soc_threshold_release_percent # Backup-planning forecast. Every field stays `None` when the panel # publishes no `shed-forecast` node, which is what lets a consumer gate diff --git a/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json index f6c2591..0aee1b8 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json +++ b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json @@ -62,8 +62,11 @@ "bess": { "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", "$state": "ready", + "info/firmware-version": "example-bess/v0.1.0", "info/model": "Example BESS", "info/nameplate-capacity": "13.5", + "info/part-number": "SPN-BESS-001", + "info/serial-number": "EXAMPLE-BESS-40T-001", "info/vendor-name": "Span", "meter/active-power": "-3500.0", "soc/soc": "50.410493827160494", @@ -76,6 +79,10 @@ "grid/grid-forming-entity": "GRID", "grid/grid-state": "UP", "grid/islanding-state": "ON_GRID", + "info/firmware-version": "example-mid/v0.1.0", + "info/hardware-version": "rev1", + "info/model": "SPAN MID", + "info/serial-number": "EXAMPLE-BESS-40T-001-mid", "info/vendor-name": "Span" }, "d3724e0d660ba506aa79c1cafe5d1181": { @@ -169,7 +176,8 @@ "status/postal-code": "94103", "status/relay": "CLOSED", "status/time-zone": "America/Los_Angeles", - "status/wifi": "true" + "status/wifi": "true", + "status/wifi-ssid": "example-wifi" }, "fe8b85c15bc9610c1b8b4ebc6f82488d": { "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", @@ -218,6 +226,7 @@ "pv": { "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", "$state": "ready", + "info/firmware-version": "example-pv/v0.1.0", "info/model": "IQ8PLUS-72-2-US", "info/nominal-power": "10000.0", "info/vendor-name": "Enphase" diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index 9eeb3fd..1d293a1 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -180,6 +180,13 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re main_breaker_rating_a=fields.main_breaker_rating_a, wifi_ssid=fields.wifi_ssid, vendor_cloud=fields.vendor_cloud, + vendor_name=fields.vendor_name, + model=fields.model, + hardware_version=fields.hardware_version, + shed_policy=fields.shed_policy, + shed_policy_algorithm=fields.shed_policy_algorithm, + shed_soc_threshold_shed_percent=fields.shed_soc_threshold_shed_percent, + shed_soc_threshold_release_percent=fields.shed_soc_threshold_release_percent, power_flow_pv=fields.power_flow_pv, power_flow_battery=fields.power_flow_battery, power_flow_grid=fields.power_flow_grid, diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 0d7e675..295e4d4 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -491,9 +491,61 @@ class SpanPanelSnapshot: l1_voltage: float | None = None # v2: core/l1-voltage (V) l2_voltage: float | None = None # v2: core/l2-voltage (V) main_breaker_rating_a: int | None = None # v2: core/breaker-rating (A) - wifi_ssid: str | None = None # v2: core/wifi-ssid + wifi_ssid: str | None = None # v1.0: status/wifi-ssid | flat: core/wifi-ssid vendor_cloud: str | None = None # v2: core/vendor-cloud + # The enclosure's own build identity, for the device card rather than for an + # entity. `None` when the panel publishes nothing, never a default string: + # the consumer has shown its own text since before these were readable, and + # a default invented here would silently replace it. v1.0 only -- flat + # declares none of the three. + vendor_name: str | None = None + """`info/vendor-name` -- who made the enclosure.""" + model: str | None = None + """`info/model` -- the enclosure's model designation, e.g. `MAIN_40`. + + The same property `panel_size` is derived from, kept as the string beside + the derived integer: the size is what circuits are built against, the + designation is what a device card shows. Spelled `model` to match + `battery.model`, `pv.model`, `evse.model` and `mid.model`, all of which name + the same `info/model` property on their own device. + """ + hardware_version: str | None = None + """`info/hardware-version` -- the enclosure's board revision. + + `hardware_version` rather than `hw_version`: the snapshot spells fields out, + and `DeviceInfo(hw_version=...)` is the consumer's abbreviation, not ours. + """ + + # `shed/policy`, v1.0 only: how the panel decides what to shed, and the two + # SoC thresholds that make its behaviour predictable. The wire carries one + # `json` document; these are the parsed answer plus the document itself. + shed_policy: str | None = None + """`shed/policy` verbatim -- the JSON document as published. + + Kept beside the parsed members rather than discarded once parsed, because + the document's schema is versioned in its own `$id` and a publisher may ship + an algorithm this library does not know. The raw string is what lets a + consumer still show what the panel said instead of showing nothing. + """ + shed_policy_algorithm: str | None = None + """The document's `algorithm` member, e.g. `soc-priority.v1`. + + `None` means the property was not published, did not parse, or named no + algorithm -- to a consumer those are one event: there is nothing to render. + A *recognised* name and an unrecognised one are both reported here; only the + thresholds below are gated on recognising it. + """ + shed_soc_threshold_shed_percent: int | None = None + """`parameters.soc-threshold-shed` -- SoC percent below which SOC_THRESHOLD circuits shed. + + Populated only from a `soc-priority.v1` document, because it is that + algorithm's parameter. `0` is a legal threshold, so `None` cannot be + replaced by a default. + """ + shed_soc_threshold_release_percent: int | None = None + """`parameters.soc-threshold-release` -- SoC percent above which shed circuits restore.""" + # Power flows (None when node not present) power_flow_pv: float | None = None # v2: power-flows/pv (W) power_flow_battery: float | None = None # v2: power-flows/battery (W) diff --git a/tests/fixtures/panelbench_unvalued_by_both.json b/tests/fixtures/panelbench_unvalued_by_both.json new file mode 100644 index 0000000..f3a5032 --- /dev/null +++ b/tests/fixtures/panelbench_unvalued_by_both.json @@ -0,0 +1,125 @@ +[ + "energy.ebus.device.circuit::Bathroom Lights connection/count", + "energy.ebus.device.circuit::Bathroom Lights connection/feeds-device-id", + "energy.ebus.device.circuit::Bathroom Lights connection/feeds-device-status", + "energy.ebus.device.circuit::Bathroom Lights connection/feeds-device-type", + "energy.ebus.device.circuit::Bedroom Lights connection/count", + "energy.ebus.device.circuit::Bedroom Lights connection/feeds-device-id", + "energy.ebus.device.circuit::Bedroom Lights connection/feeds-device-status", + "energy.ebus.device.circuit::Bedroom Lights connection/feeds-device-type", + "energy.ebus.device.circuit::Chest Freezer connection/count", + "energy.ebus.device.circuit::Chest Freezer connection/feeds-device-id", + "energy.ebus.device.circuit::Chest Freezer connection/feeds-device-status", + "energy.ebus.device.circuit::Chest Freezer connection/feeds-device-type", + "energy.ebus.device.circuit::Dishwasher connection/count", + "energy.ebus.device.circuit::Dishwasher connection/feeds-device-id", + "energy.ebus.device.circuit::Dishwasher connection/feeds-device-status", + "energy.ebus.device.circuit::Dishwasher connection/feeds-device-type", + "energy.ebus.device.circuit::Electric Dryer connection/count", + "energy.ebus.device.circuit::Electric Dryer connection/feeds-device-id", + "energy.ebus.device.circuit::Electric Dryer connection/feeds-device-status", + "energy.ebus.device.circuit::Electric Dryer connection/feeds-device-type", + "energy.ebus.device.circuit::Electric Oven/Range connection/count", + "energy.ebus.device.circuit::Electric Oven/Range connection/feeds-device-id", + "energy.ebus.device.circuit::Electric Oven/Range connection/feeds-device-status", + "energy.ebus.device.circuit::Electric Oven/Range connection/feeds-device-type", + "energy.ebus.device.circuit::Exterior Lights connection/count", + "energy.ebus.device.circuit::Exterior Lights connection/feeds-device-id", + "energy.ebus.device.circuit::Exterior Lights connection/feeds-device-status", + "energy.ebus.device.circuit::Exterior Lights connection/feeds-device-type", + "energy.ebus.device.circuit::Garage Outlets connection/count", + "energy.ebus.device.circuit::Garage Outlets connection/feeds-device-id", + "energy.ebus.device.circuit::Garage Outlets connection/feeds-device-status", + "energy.ebus.device.circuit::Garage Outlets connection/feeds-device-type", + "energy.ebus.device.circuit::Garbage Disposal connection/count", + "energy.ebus.device.circuit::Garbage Disposal connection/feeds-device-id", + "energy.ebus.device.circuit::Garbage Disposal connection/feeds-device-status", + "energy.ebus.device.circuit::Garbage Disposal connection/feeds-device-type", + "energy.ebus.device.circuit::Guest Room Outlets connection/count", + "energy.ebus.device.circuit::Guest Room Outlets connection/feeds-device-id", + "energy.ebus.device.circuit::Guest Room Outlets connection/feeds-device-status", + "energy.ebus.device.circuit::Guest Room Outlets connection/feeds-device-type", + "energy.ebus.device.circuit::Heat Pump connection/count", + "energy.ebus.device.circuit::Heat Pump connection/feeds-device-id", + "energy.ebus.device.circuit::Heat Pump connection/feeds-device-status", + "energy.ebus.device.circuit::Heat Pump connection/feeds-device-type", + "energy.ebus.device.circuit::Kitchen Outlets (Counter) connection/count", + "energy.ebus.device.circuit::Kitchen Outlets (Counter) connection/feeds-device-id", + "energy.ebus.device.circuit::Kitchen Outlets (Counter) connection/feeds-device-status", + "energy.ebus.device.circuit::Kitchen Outlets (Counter) connection/feeds-device-type", + "energy.ebus.device.circuit::Kitchen Outlets (Island) connection/count", + "energy.ebus.device.circuit::Kitchen Outlets (Island) connection/feeds-device-id", + "energy.ebus.device.circuit::Kitchen Outlets (Island) connection/feeds-device-status", + "energy.ebus.device.circuit::Kitchen Outlets (Island) connection/feeds-device-type", + "energy.ebus.device.circuit::Laundry Room Outlets connection/count", + "energy.ebus.device.circuit::Laundry Room Outlets connection/feeds-device-id", + "energy.ebus.device.circuit::Laundry Room Outlets connection/feeds-device-status", + "energy.ebus.device.circuit::Laundry Room Outlets connection/feeds-device-type", + "energy.ebus.device.circuit::Living Room Lights connection/count", + "energy.ebus.device.circuit::Living Room Lights connection/feeds-device-id", + "energy.ebus.device.circuit::Living Room Lights connection/feeds-device-status", + "energy.ebus.device.circuit::Living Room Lights connection/feeds-device-type", + "energy.ebus.device.circuit::Living Room Outlets connection/count", + "energy.ebus.device.circuit::Living Room Outlets connection/feeds-device-id", + "energy.ebus.device.circuit::Living Room Outlets connection/feeds-device-status", + "energy.ebus.device.circuit::Living Room Outlets connection/feeds-device-type", + "energy.ebus.device.circuit::Main HVAC connection/count", + "energy.ebus.device.circuit::Main HVAC connection/feeds-device-id", + "energy.ebus.device.circuit::Main HVAC connection/feeds-device-status", + "energy.ebus.device.circuit::Main HVAC connection/feeds-device-type", + "energy.ebus.device.circuit::Master Bedroom Lights connection/count", + "energy.ebus.device.circuit::Master Bedroom Lights connection/feeds-device-id", + "energy.ebus.device.circuit::Master Bedroom Lights connection/feeds-device-status", + "energy.ebus.device.circuit::Master Bedroom Lights connection/feeds-device-type", + "energy.ebus.device.circuit::Master Bedroom Outlets connection/count", + "energy.ebus.device.circuit::Master Bedroom Outlets connection/feeds-device-id", + "energy.ebus.device.circuit::Master Bedroom Outlets connection/feeds-device-status", + "energy.ebus.device.circuit::Master Bedroom Outlets connection/feeds-device-type", + "energy.ebus.device.circuit::Microwave connection/count", + "energy.ebus.device.circuit::Microwave connection/feeds-device-id", + "energy.ebus.device.circuit::Microwave connection/feeds-device-status", + "energy.ebus.device.circuit::Microwave connection/feeds-device-type", + "energy.ebus.device.circuit::Office Outlets connection/count", + "energy.ebus.device.circuit::Office Outlets connection/feeds-device-id", + "energy.ebus.device.circuit::Office Outlets connection/feeds-device-status", + "energy.ebus.device.circuit::Office Outlets connection/feeds-device-type", + "energy.ebus.device.circuit::Pool Pump connection/count", + "energy.ebus.device.circuit::Pool Pump connection/feeds-device-id", + "energy.ebus.device.circuit::Pool Pump connection/feeds-device-status", + "energy.ebus.device.circuit::Pool Pump connection/feeds-device-type", + "energy.ebus.device.circuit::Refrigerator connection/count", + "energy.ebus.device.circuit::Refrigerator connection/feeds-device-id", + "energy.ebus.device.circuit::Refrigerator connection/feeds-device-status", + "energy.ebus.device.circuit::Refrigerator connection/feeds-device-type", + "energy.ebus.device.circuit::SPAN Drive - Driveway connection/count", + "energy.ebus.device.circuit::SPAN Drive - Garage connection/count", + "energy.ebus.device.circuit::Smoke Detectors connection/count", + "energy.ebus.device.circuit::Smoke Detectors connection/feeds-device-id", + "energy.ebus.device.circuit::Smoke Detectors connection/feeds-device-status", + "energy.ebus.device.circuit::Smoke Detectors connection/feeds-device-type", + "energy.ebus.device.circuit::Solar Inverter connection/count", + "energy.ebus.device.circuit::Washing Machine connection/count", + "energy.ebus.device.circuit::Washing Machine connection/feeds-device-id", + "energy.ebus.device.circuit::Washing Machine connection/feeds-device-status", + "energy.ebus.device.circuit::Washing Machine connection/feeds-device-type", + "energy.ebus.device.circuit::Water Heater connection/count", + "energy.ebus.device.circuit::Water Heater connection/feeds-device-id", + "energy.ebus.device.circuit::Water Heater connection/feeds-device-status", + "energy.ebus.device.circuit::Water Heater connection/feeds-device-type", + "energy.ebus.device.circuit::kitchen Lights connection/count", + "energy.ebus.device.circuit::kitchen Lights connection/feeds-device-id", + "energy.ebus.device.circuit::kitchen Lights connection/feeds-device-status", + "energy.ebus.device.circuit::kitchen Lights connection/feeds-device-type", + "energy.ebus.device.lugs::Downstream lugs connection/count", + "energy.ebus.device.lugs::Downstream lugs connection/fed-by-device-id", + "energy.ebus.device.lugs::Downstream lugs connection/fed-by-device-status", + "energy.ebus.device.lugs::Downstream lugs connection/fed-by-device-type", + "energy.ebus.device.lugs::Downstream lugs connection/feeds-device-id", + "energy.ebus.device.lugs::Downstream lugs connection/feeds-device-status", + "energy.ebus.device.lugs::Downstream lugs connection/feeds-device-type", + "energy.ebus.device.lugs::Upstream lugs connection/count", + "energy.ebus.device.lugs::Upstream lugs connection/feeds-device-id", + "energy.ebus.device.lugs::Upstream lugs connection/feeds-device-status", + "energy.ebus.device.lugs::Upstream lugs connection/feeds-device-type", + "energy.ebus.device.pv::Solar info/serial-number" +] diff --git a/tests/test_reference_tree_values.py b/tests/test_reference_tree_values.py new file mode 100644 index 0000000..94ccfaf --- /dev/null +++ b/tests/test_reference_tree_values.py @@ -0,0 +1,140 @@ +"""What the reference tree leaves unvalued must be what the producer leaves unvalued. + +`parent_child_tree.json` is shipped package data and the fixture every schema_1 +test is written against, so what it *publishes* is the whole evidence base for +"does this adapter read that property". A property the producer values and this +capture does not is therefore invisible in both directions at once: no test can +fail for not reading it, and no consumer test can fail for not surfacing it. + +That is not hypothetical. The capture was trimmed and renamed by hand from a +panelbench run, and by 2026-08-19 it had drifted eight properties behind — MID +`info/{model,serial-number,firmware-version,hardware-version}`, BESS +`info/{part-number,serial-number,firmware-version}` and PV +`info/firmware-version` were all published by the producer and absent here. Four +library tests had been written to inject the values by hand precisely because +the fixture did not carry them, which reads as coverage and is not: injecting a +value asks whether the mapper can read a property, never whether the panel sends +one. The drift was found by comparing the two artifacts by hand, which is a +thing nobody does twice — hence this. + +**Compared at device-type granularity, and it has to be.** This capture is +`sim-*` renamed to `example-*` and cut from 28 circuits to 5, with two of them +renamed in passing (`kitchen Lights` -> `Kitchen Lights`, `Garage Outlets` -> +`Garage Outlet`), so a per-device comparison would fail on the rename rather +than on a value. Type granularity is also the granularity the question is asked +at: five circuits declare the same properties, and the same one going unvalued +on all five is one gap, not five. The same choice the integration's +`test_declared_but_unread` makes, for the same reason. + +The reduction loses nothing here, and that is measured rather than assumed: +reduced the same way, panelbench's baseline is exactly the declared-but-unvalued +set of its own committed wire capture (`tests/conformance/fixtures/golden_wire.json`). + +Refresh the vendored copy with: + + cp ../panelbench/tests/fidelity/fixtures/unvalued_by_both_baseline.json \ + tests/fixtures/panelbench_unvalued_by_both.json + +It is vendored verbatim rather than pre-reduced so that refreshing it is a copy +whose correctness a reader can check with `diff`, and so the reduction stays +here where it is explained. +""" + +from __future__ import annotations + +from collections import defaultdict +import json +import pathlib + +from span_panel_api_schema_1.reference_payloads import parent_child_tree + +_PANELBENCH_BASELINE = pathlib.Path(__file__).parent / "fixtures" / "panelbench_unvalued_by_both.json" + + +def _device_type(qualified: str) -> str: + """`energy.ebus.device.circuit` -> `circuit`.""" + return qualified.rsplit(".", 1)[-1] + + +def _fixture_unvalued() -> dict[str, set[str]]: + """Every `node/property` this capture declares and never publishes, by device type.""" + unvalued: dict[str, set[str]] = defaultdict(set) + for topics in parent_child_tree().values(): + description = json.loads(topics["$description"]) + declared = { + f"{node_id}/{property_id}" + for node_id, node in description.get("nodes", {}).items() + for property_id in node.get("properties", {}) + } + published = {topic for topic in topics if not topic.startswith("$")} + unvalued[_device_type(description["type"])] |= declared - published + return {device_type: topics for device_type, topics in unvalued.items() if topics} + + +def _panelbench_unvalued() -> dict[str, set[str]]: + """Panelbench's baseline, reduced the same way. + + Its lines are `{device type}::{device name} {node}/{property}`; the name is + what the trim and the rename make uncomparable, so it is what the reduction + drops. + """ + unvalued: dict[str, set[str]] = defaultdict(set) + for line in json.loads(_PANELBENCH_BASELINE.read_text(encoding="utf-8")): + identity, _, topic = line.partition(" ") + unvalued[_device_type(identity.split("::", 1)[0])].add(topic) + return dict(unvalued) + + +def test_the_reference_tree_values_everything_the_producer_values() -> None: + """Fails in both directions, so neither drift nor a stale baseline survives. + + A property the producer starts valuing and this capture does not fails as a + gap in the evidence base. A property this capture values that the producer + does not fails too: the capture would be asserting a value nothing on the + wire produces, which is a fixture that tests the parser against fiction. + """ + fixture = _fixture_unvalued() + producer = _panelbench_unvalued() + + missing = { + device_type: sorted(topics - producer.get(device_type, set())) + for device_type, topics in fixture.items() + if topics - producer.get(device_type, set()) + } + invented = { + device_type: sorted(topics - fixture.get(device_type, set())) + for device_type, topics in producer.items() + if topics - fixture.get(device_type, set()) + } + + assert fixture == producer, ( + "the reference tree and the producer disagree about what stays unvalued.\n" + f" unvalued here, valued by the producer (port the value in):\n {missing}\n" + f" unvalued by the producer, valued here (the capture invented it):\n {invented}\n\n" + "Port values into the existing artifact rather than recapturing it: the ids are " + "synthetic, the circuit set is trimmed, and the meter values are not reproducible." + ) + + +def test_the_held_pv_serial_is_still_the_only_singleton_left() -> None: + """PV `info/serial-number` is unvalued on purpose, and must stay that way. + + `_der_identifier` prefers a serial over the instance id, so valuing it moves + the PV device id from `-pv-1` to `-`. A consumer keys + its device registry on that id, which turns an upgrade rehearsal into a + device-replacement rehearsal. Pinned separately from the set comparison + above because that one would go on passing if both sides gained the value + together, and this is the one line where agreeing would be the mistake. + """ + assert _fixture_unvalued()["pv"] == {"info/serial-number"} + + +def test_every_property_the_producer_values_on_the_panel_is_valued_here() -> None: + """The enclosure carries no unvalued declaration at all, and that is the point. + + `status/wifi-ssid` was the last one, and it is the property whose absence + hid a flat -> v1.0 regression: nothing read it because nothing published it, + and nothing published it because the capture had not been refreshed. An + empty set here is a measurement, and this test is what keeps it one. + """ + assert "distribution-enclosure" not in _fixture_unvalued() diff --git a/tests/test_schema_one_conformance.py b/tests/test_schema_one_conformance.py index be1f869..676365b 100644 --- a/tests/test_schema_one_conformance.py +++ b/tests/test_schema_one_conformance.py @@ -191,6 +191,12 @@ def _simulator_declared() -> set[tuple[str, str]]: (const.NODE_STATUS, "relay"): "panel main relay position; the catalog's status is alerts and comms only", (const.NODE_STATUS, "ethernet"): "panel ethernet link state", (const.NODE_STATUS, "wifi"): "panel wifi link state", + (const.NODE_STATUS, "wifi-ssid"): ( + "the network the panel is joined to. Declared on the enclosure and documented by " + "r202633 as the MQTT successor to the flat Wi-Fi endpoint, but absent from the " + "status catalog, which is alerts and comms only -- the same reason its `wifi` " + "sibling above is an extension." + ), (const.NODE_STATUS, "cloud-connection"): "panel vendor-cloud reachability", (const.NODE_STATUS, "status"): "EVSE session status", (const.NODE_METER, "voltage-a"): "split-phase per-leg voltage; the catalog carries a single voltage", diff --git a/tests/test_schema_one_devices.py b/tests/test_schema_one_devices.py index 1ed13b1..8f23103 100644 --- a/tests/test_schema_one_devices.py +++ b/tests/test_schema_one_devices.py @@ -64,6 +64,17 @@ def _bess_with(overrides: Mapping[str, str | None]) -> DiscoveredDevice: return device_from_topics("bess", topics) +def _without(device_id: str, *topics: str) -> DiscoveredDevice: + """The captured device with these topics unpublished. + + The counterpart of `_published`: identity values now arrive valued in the + capture, so proving a consumer distinguishes "not published" from "published + blank" needs the absence built rather than found. + """ + remaining = {topic: value for topic, value in _TREE[device_id].items() if topic not in topics} + return device_from_topics(device_id, remaining) + + def _bess_without_node(node_id: str) -> DiscoveredDevice: """The captured BESS with one capability node gone from its `$description`. @@ -373,20 +384,35 @@ def test_the_mid_is_surfaced_as_its_own_device() -> None: enclosure device itself does not publish them" -- so this is where islanding state, grid state and the grid-forming entity actually live. - The reference tree's MID publishes no serial, because upstream's example config - declares no BESS serial for it to derive one from. That exercises the fallback: - identity drops to the Homie device id. `test_the_mid_identity_is_its_serial` covers - the path that matters more, against a capture that has one. + Identity is the published serial, per `devices/proxy.md`: a proxied device id is + not stable across the proxy-to-native transition, so it cannot be what a consumer + keys its registry on. `test_the_mid_falls_back_to_its_device_id_without_a_serial` + covers the other branch. """ mid = build_mid(_device("bess-mid"), {}) assert mid is not None - assert mid.islanding_state == "ON_GRID" - assert mid.grid_state == "UP" - assert mid.grid_forming_entity == "GRID" - assert mid.vendor_name == "Span" - assert mid.node_id == "bess-mid", "with no serial published, identity falls back to the device id" + assert mid.islanding_state == _published("bess-mid", "grid/islanding-state") + assert mid.grid_state == _published("bess-mid", "grid/grid-state") + assert mid.grid_forming_entity == _published("bess-mid", "grid/grid-forming-entity") + assert mid.vendor_name == _published("bess-mid", "info/vendor-name") + assert mid.model == _published("bess-mid", "info/model") + assert mid.serial_number == _published("bess-mid", "info/serial-number") + assert mid.node_id == mid.serial_number + + +def test_the_mid_falls_back_to_its_device_id_without_a_serial() -> None: + """A MID that publishes no serial still gets an identity, from the Homie device id. + + The fallback branch of the rule above. It was the capture's own state until the + reference tree caught up with what the producer publishes, so it is written out + rather than left to a fixture that happens not to carry a value. + """ + mid = build_mid(_without("bess-mid", "info/serial-number"), {}) + + assert mid is not None assert mid.serial_number is None + assert mid.node_id == "bess-mid" def test_a_panel_with_no_mid_reports_none_rather_than_an_empty_device() -> None: @@ -412,16 +438,16 @@ def test_the_mid_carries_its_own_firmware_and_hardware_revision() -> None: `software_version` rather than `firmware_version`: the sub-devices share a spelling because a consumer builds all of them the same way. Only the enclosure calls it `firmware_version`. - """ - device = _device("bess-mid") - device.update_property("info", "firmware-version", "sim-mid/v0.1.0") - device.update_property("info", "hardware-version", "rev1") - mid = build_mid(device, {}) + Read straight off the capture now that it carries what the producer publishes; + it used to inject the two values, which asked whether the mapper could read a + property this tree did not have. + """ + mid = build_mid(_device("bess-mid"), {}) assert mid is not None - assert mid.software_version == "sim-mid/v0.1.0" - assert mid.hardware_version == "rev1" + assert mid.software_version == _published("bess-mid", "info/firmware-version") + assert mid.hardware_version == _published("bess-mid", "info/hardware-version") def test_the_pv_carries_its_firmware_version() -> None: @@ -431,22 +457,21 @@ def test_the_pv_carries_its_firmware_version() -> None: now. Unlike the MID there is no `hardware-version` to carry — the topic reference documents five properties on the PV and that is not one of them. """ - device = _device("pv") - device.update_property("info", "firmware-version", "sim-pv/v0.1.0") + pv = build_pv(_device("pv"), {}, feed_statuses={}) - assert build_pv(device, {}, feed_statuses={}).software_version == "sim-pv/v0.1.0" + assert pv.software_version == _published("pv", "info/firmware-version") def test_a_device_publishing_no_revision_reports_none_rather_than_empty_string() -> None: """Absent stays absent, so a consumer can tell "not published" from "published blank". `DeviceInfo` renders an empty string as a present-but-blank row; `None` omits the - row. The reference tree publishes neither property, which is what makes it the - right fixture for this. + row. The capture publishes all three, so the absence is built by unpublishing + them, which is what a panel whose firmware omits them actually looks like. """ - mid = build_mid(_device("bess-mid"), {}) + mid = build_mid(_without("bess-mid", "info/firmware-version", "info/hardware-version"), {}) assert mid is not None assert mid.software_version is None assert mid.hardware_version is None - assert build_pv(_device("pv"), {}, feed_statuses={}).software_version is None + assert build_pv(_without("pv", "info/firmware-version"), {}, feed_statuses={}).software_version is None diff --git a/tests/test_schema_one_panel.py b/tests/test_schema_one_panel.py index f23e84a..0e8aad6 100644 --- a/tests/test_schema_one_panel.py +++ b/tests/test_schema_one_panel.py @@ -37,6 +37,40 @@ def _device(device_id: str) -> DiscoveredDevice: return device_from_topics(device_id, _TREE[device_id]) +def _published(device_id: str, topic: str) -> str: + """What the capture publishes on this topic, or fail saying it does not. + + Expectations are computed from this rather than written as literals, so a + test cannot keep passing against a capture that stopped carrying the value + it is about. + """ + value = _TREE[device_id].get(topic) + assert value is not None, f"{device_id} publishes no {topic} in the capture" + return value + + +def _panel_with(**overrides: str | None) -> DiscoveredDevice: + """The captured panel with topics rewritten, or unpublished where `None`. + + Keyword spelling is `node__property_name`, matching `_synthetic` below. + Unpublishing is what a panel whose firmware omits a property looks like, and + it is a different event from publishing an empty string. + """ + topics = dict(_TREE[PANEL]) + for path, value in overrides.items(): + node, _, prop = path.partition("__") + topic = f"{node.replace('_', '-')}/{prop.replace('_', '-')}" + if value is None: + topics.pop(topic, None) + else: + topics[topic] = value + return device_from_topics(PANEL, topics) + + +def _fields_for(panel: DiscoveredDevice) -> PanelFields: + return PanelFields(panel=panel, upstream_lugs=None, downstream_lugs=None, mid=None) + + @pytest.fixture(name="fields") def _fields() -> PanelFields: return PanelFields( @@ -428,3 +462,121 @@ def test_the_forming_device_is_named_readably_not_by_wire_id() -> None: assert resolve_grid_forming_device_name(_synthetic("mid", grid__grid_forming_entity="GRID"), names) is None # Unresolvable: the raw id stays on `grid_forming_entity` for anyone who needs it. assert resolve_grid_forming_device_name(_synthetic("mid", grid__grid_forming_entity="ghost"), names) is None + + +def test_the_panel_reads_the_network_it_is_joined_to(fields: PanelFields) -> None: + """`status/wifi-ssid`, the property whose absence was a flat -> v1.0 regression. + + Flat published `core/wifi-ssid`, the integration surfaces it as an attribute, + and schema_1 initialised the field to `None` and mapped nothing to it. Every + conformance check agreed that was fine, because each of them asks whether a + declaration has a reader and none asks whether a *user-visible* value + survived the schema change. + """ + assert fields.wifi_ssid == _published(PANEL, "status/wifi-ssid") + + +def test_an_unpublished_ssid_stays_absent_rather_than_becoming_empty() -> None: + """`None`, not `""`: the consumer omits the attribute entirely for `None`.""" + assert _fields_for(_panel_with(status__wifi_ssid=None)).wifi_ssid is None + assert _fields_for(_panel_with(status__wifi_ssid="")).wifi_ssid is None + + +def test_the_panel_carries_its_own_build_identity(fields: PanelFields) -> None: + """Vendor, model and hardware revision, for the enclosure's device card. + + The model string is the same property `panel_size` is derived from, kept + beside the derived integer rather than instead of it: the size builds + circuits, the designation is what a person reads on a device card. + """ + assert fields.vendor_name == _published(PANEL, "info/vendor-name") + assert fields.model == _published(PANEL, "info/model") + assert fields.hardware_version == _published(PANEL, "info/hardware-version") + + +def test_a_panel_publishing_no_identity_reports_none_so_a_consumer_can_fall_back() -> None: + """Absence must be `None`, because the consumer owns the fallback text. + + The integration has shown "Span" and "SPAN Panel" on the panel's device card + since before either was readable. A default invented here would replace that + text with a different one, on every panel that publishes nothing, which is a + change no user asked for and none would recognise as ours. + """ + bare = _fields_for(_panel_with(info__vendor_name=None, info__model=None, info__hardware_version=None)) + + assert bare.vendor_name is None + assert bare.model is None + assert bare.hardware_version is None + + +def test_the_shed_policy_is_parsed_into_its_algorithm_and_thresholds(fields: PanelFields) -> None: + """`shed/policy` is a JSON document; the two SoC thresholds are what a consumer shows. + + Asserted against the document the capture publishes rather than against + literals, so the parse is checked against the producer's own encoding. + """ + document = json.loads(_published(PANEL, "shed/policy")) + + assert fields.shed_policy == _published(PANEL, "shed/policy") + assert fields.shed_policy_algorithm == document["algorithm"] + assert fields.shed_soc_threshold_shed_percent == document["parameters"]["soc-threshold-shed"] + assert fields.shed_soc_threshold_release_percent == document["parameters"]["soc-threshold-release"] + + +def test_an_unknown_shed_algorithm_keeps_its_name_and_yields_no_thresholds() -> None: + """The `$format` schema is versioned in its own `$id`, so another algorithm may arrive. + + A reader that assumed `soc-priority.v1` would report that algorithm's + thresholds for a document that never had them. Naming the algorithm and + declining the numbers is the honest answer, and the raw document stays + available beside it. + """ + other = json.dumps({"algorithm": "runtime-priority.v2", "parameters": {"minutes-shed": 30}}) + parsed = _fields_for(_panel_with(shed__policy=other)) + + assert parsed.shed_policy == other, "the raw document survives so a consumer can still show it" + assert parsed.shed_policy_algorithm == "runtime-priority.v2" + assert parsed.shed_soc_threshold_shed_percent is None + assert parsed.shed_soc_threshold_release_percent is None + + +@pytest.mark.parametrize( + "policy", + [ + pytest.param("not json at all", id="unparseable"), + pytest.param("[1, 2, 3]", id="not-an-object"), + pytest.param('{"algorithm": "soc-priority.v1"}', id="no-parameters"), + pytest.param('{"algorithm": "soc-priority.v1", "parameters": "20"}', id="parameters-not-an-object"), + pytest.param( + '{"algorithm": "soc-priority.v1", "parameters": {"soc-threshold-shed": "low"}}', + id="threshold-not-a-number", + ), + pytest.param( + '{"algorithm": "soc-priority.v1", "parameters": {"soc-threshold-shed": true}}', + id="threshold-is-a-bool", + ), + ], +) +def test_a_malformed_shed_policy_degrades_rather_than_raising(policy: str) -> None: + """Every failure lands on "nothing to render", which is what absence means too. + + A panel is a publisher this library does not control, and a snapshot build + that raises on one bad string takes every other entity down with it. The + boolean case is called out because `bool` is an `int` in Python: `true` + would otherwise read as a 1% threshold. + """ + parsed = _fields_for(_panel_with(shed__policy=policy)) + + assert parsed.shed_policy == policy + assert parsed.shed_soc_threshold_shed_percent is None + assert parsed.shed_soc_threshold_release_percent is None + + +def test_an_unpublished_shed_policy_reports_nothing_at_all() -> None: + """A panel with no policy published is not a panel with an unparseable one.""" + absent = _fields_for(_panel_with(shed__policy=None)) + + assert absent.shed_policy is None + assert absent.shed_policy_algorithm is None + assert absent.shed_soc_threshold_shed_percent is None + assert absent.shed_soc_threshold_release_percent is None From 5e233ebd1e4fa4f2097c14d30886899d612241e2 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:38:02 -0700 Subject: [PATCH 086/115] feat(schema-1): read and write the EVSE charge-current ceiling from its declaration The only settable property the v1.0 catch-up surfaces, and the only one whose wire name is unsettled: the reference tree declares `config/max-charge-current` and `config/user-max-charge-current`, while the eBus catalog has no `config` capability at all and puts the surface on `charge-limit` 0.1 (`installer-max`, settable `owner-limit`). No capture can decide between them -- the panel we expect access to carries no SPAN Drive -- so waiting was not a plan that terminates. It does not need to be decided. `distribution-enclosure.md` makes a device's `$description` the authoritative property set, so `charge_limit.py` names no node outside a spelling table: it asks the charger which naming it declares, reads that one, and builds the set topic from the node and property it found. Both spellings produce an identical snapshot, which is asserted field by field rather than claimed. Reading (`SpanEvseSnapshot`, named for the concept and not for either spelling): - `charge_current_limit_a` -- the ceiling a user may lower - `charge_current_ceiling_a` -- the commissioned maximum it may not exceed - `charge_current_limit_target_a` -- the Homie `$target` echo, the same pending-command signal `circuit.priority_target` already carries - `charge_current_limit_settable` -- read from `$settable`, defaulting to **False**. Opposite to `_priority_is_settable`, and deliberately: the ceiling and the limit differ by that attribute alone, so a permissive default would make the installer's commissioned maximum look writable. Writing, through two new adapter methods shaped like the dominant-power-source pair, so the transport refuses rather than guesses: - `set_evse_charge_limit_topic` returns None where the property is not declared settable, and addresses the charger by *device id* while the caller holds the snapshot's serial-harmonised key -- two different strings on any panel that publishes a serial, so the lookup goes through `harmonised_evse_keys` rather than being rebuilt. - `evse_charge_limit_payload` refuses above the commissioned ceiling rather than clamping. `charge-limit` 0.1 makes `owner-limit <= installer-max` a MUST and the ceiling is derated hardware protection; a silent clamp would report a limit the charger is not enforcing. A charger declaring no ceiling is not second-guessed -- `installer-max` is a SHOULD, and inventing a bound here would be this library making up hardware limits. The flat adapter answers None to both: its `evse` type carries `advertised-current` and nothing that sets it, so there is no property to aim a topic at. Catalog gate: `charge-limit.json` is vendored byte-identical at the pinned `synced_commit` and added to `implements.capabilities`, so the catalogued spelling is checked like every other name. `config` cannot be -- no such capability exists upstream -- so its two properties are declared in `_SPAN_EXTENSIONS` with reasons, and the node-vendoring check now derives its tolerance from that allowlist: a node needs a catalog unless *every* property read on it is an extension somebody claimed deliberately. A new opportunistic provenance check fails the day the specification defines a capability under an excused name. Metadata for the pair goes through the same `resolve_charge_limit` the value does, for the reason `_lugs_metadata` split from the table: the unit a field advertises and the value that fills it must describe the same property, and a table keyed `(type, node, property)` cannot hold a node the charger chooses. `description.py` is new and holds the `$description` narrowing `field_metadata` already did privately, so the two readers narrow identically and neither needs its own copy. --- .../src/span_panel_api_schema_0/adapter.py | 20 + .../schema-1/spec/catalogs/charge-limit.json | 37 ++ .../src/span_panel_api_schema_1/adapter.py | 80 ++- .../span_panel_api_schema_1/charge_limit.py | 170 ++++++ .../span_panel_api_schema_1/description.py | 55 ++ .../src/span_panel_api_schema_1/devices.py | 40 +- .../span_panel_api_schema_1/field_metadata.py | 76 ++- .../src/span_panel_api_schema_1/snapshot.py | 9 +- .../span_panel_api_schema_1/spec_lock.json | 3 +- src/span_panel_api/__init__.py | 7 + src/span_panel_api/models.py | 47 ++ src/span_panel_api/mqtt/client.py | 26 + src/span_panel_api/protocol.py | 37 ++ tests/test_protocol_conformance.py | 7 + tests/test_public_api_unchanged.py | 4 + tests/test_schema_one_charge_limit.py | 540 ++++++++++++++++++ tests/test_schema_one_conformance.py | 87 ++- 17 files changed, 1212 insertions(+), 33 deletions(-) create mode 100644 packages/schema-1/spec/catalogs/charge-limit.json create mode 100644 packages/schema-1/src/span_panel_api_schema_1/charge_limit.py create mode 100644 packages/schema-1/src/span_panel_api_schema_1/description.py create mode 100644 tests/test_schema_one_charge_limit.py diff --git a/packages/schema-0/src/span_panel_api_schema_0/adapter.py b/packages/schema-0/src/span_panel_api_schema_0/adapter.py index 1673c68..9e14bed 100644 --- a/packages/schema-0/src/span_panel_api_schema_0/adapter.py +++ b/packages/schema-0/src/span_panel_api_schema_0/adapter.py @@ -91,5 +91,25 @@ def dominant_power_source_payload(self, value: str) -> str | None: candidate = value.strip().upper() return candidate if candidate in allowed else None + def set_evse_charge_limit_topic(self, node_id: str) -> str | None: # pylint: disable=unused-argument + """None: flat firmware publishes no charge-current ceiling to write. + + The flat `energy.ebus.device.evse` type carries `advertised-current` — + what the charger is offering the vehicle, read-only — and nothing that + sets it. There is no property to aim a set topic at, so the transport + refuses the command rather than publishing to a topic no panel of this + generation subscribes to. + + `node_id` is accepted and unused for the same reason + `set_dominant_power_source_topic` takes no arguments and still returns + None on a panel with no core node: the answer does not depend on which + charger is asked. + """ + return None + + def evse_charge_limit_payload(self, node_id: str, amps: int) -> str | None: # pylint: disable=unused-argument + """None, for the same reason: no property, so no representable value.""" + return None + def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]: return self._consumer.register_property_callback(callback) diff --git a/packages/schema-1/spec/catalogs/charge-limit.json b/packages/schema-1/spec/catalogs/charge-limit.json new file mode 100644 index 0000000..456f725 --- /dev/null +++ b/packages/schema-1/spec/catalogs/charge-limit.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.charge-limit", + "version": "0.1", + "status": "DRAFT", + "date": "2026-07-11", + "properties": { + "installer-max": { + "datatype": "integer", + "unit": "A", + "req": "SHOULD", + "description": "Installer-configured maximum charge current (breaker rating, J1772 derating): the immutable ceiling." + }, + "owner-limit": { + "datatype": "integer", + "unit": "A", + "settable": true, + "req": "MAY", + "description": "The owner's charge-current ceiling. Held until changed (\"until further notice\"), not a bounded duration. MUST be `<= installer-max`." + }, + "requested-limit": { + "datatype": "integer", + "unit": "A", + "settable": true, + "req": "MAY", + "description": "An external controller's (HEMS / grid) charge-current ceiling." + }, + "requested-limit-cause": { + "datatype": "enum", + "format": "LOCAL_OPTIMIZATION,GRID_OPTIMIZATION,UNKNOWN", + "req": "MAY", + "description": "Why the external limit is set: `LOCAL_OPTIMIZATION`, `GRID_OPTIMIZATION`, `UNKNOWN`. Records who is reducing charging and why (for attribution and consent)." + } + } +} diff --git a/packages/schema-1/src/span_panel_api_schema_1/adapter.py b/packages/schema-1/src/span_panel_api_schema_1/adapter.py index 2a1a124..c13187a 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/adapter.py +++ b/packages/schema-1/src/span_panel_api_schema_1/adapter.py @@ -22,6 +22,7 @@ from ebus_sdk import Controller +from span_panel_api_schema_1.charge_limit import ChargeLimitProperty, ChargeLimitSurface, resolve_charge_limit from span_panel_api_schema_1.const import ( HOMIE_DOMAIN, HOMIE_VERSION, @@ -37,7 +38,8 @@ STATE_READY, ) from span_panel_api_schema_1.field_metadata import build_field_metadata -from span_panel_api_schema_1.snapshot import TreeRoles, build_snapshot, device_type +from span_panel_api_schema_1.panel import integer +from span_panel_api_schema_1.snapshot import TreeRoles, build_snapshot, device_type, harmonised_evse_keys from span_panel_api_schema_1.transport import ControllerRoutes if TYPE_CHECKING: @@ -242,6 +244,57 @@ def dominant_power_source_payload(self, value: str) -> str | None: "UNKNOWN": "NONE", }.get(value.strip().upper()) + def set_evse_charge_limit_topic(self, node_id: str) -> str | None: + """The set topic for one charger's charge-current limit, or None. + + Every part of the topic is resolved at runtime. The device id comes from + matching `node_id` — the snapshot's harmonised key, which is the + charger's *serial* wherever it publishes one — back to the device the + tree actually carries, because the topic is addressed by device id and + those two are different strings on every panel that publishes a serial. + The node and property come from the charger's own `$description` through + `resolve_charge_limit`, so no spelling is baked in here. + + **None where the property is not declared settable**, which is the + refusal this control exists to make safe. Absence of `$settable` reads as + read-only (see `charge_limit`), so a charger that declares only its + commissioned ceiling gets no set topic at all rather than one aimed at a + property the panel will reject — or worse, accept. + """ + writable = self._writable_charge_limit(node_id) + if writable is None: + return None + device, surface, limit = writable + return self._set_topic(device.device_id, surface.node, limit.property_id) + + def evse_charge_limit_payload(self, node_id: str, amps: int) -> str | None: + """The payload to publish for `amps`, or None if it may not be published. + + Refuses above the commissioned ceiling. `charge-limit` 0.1 states it as a + MUST — `owner-limit` "MUST be `<= installer-max`" — and the ceiling is + derated hardware protection (breaker rating, J1772), so publishing past + it is the one write here with a physical consequence. The panel would be + entitled to clamp, reject, or fault; a consumer that clamped silently on + this side would report a limit the charger is not enforcing. + + Negative amps are refused for the same reason and no other: a + charge-only EVSE cannot be told to export by lowering a ceiling, so a + negative value is not a smaller limit but a malformed one. + + A charger that declares no ceiling is not second-guessed — the catalog + makes `installer-max` a SHOULD, and the value that bounds the write is + the one the panel published, not one this library invents. + """ + writable = self._writable_charge_limit(node_id) + if writable is None or amps < 0: + return None + device, surface, _limit = writable + ceiling = surface.ceiling + commissioned = None if ceiling is None else integer(device, surface.node, ceiling.property_id) + if commissioned is not None and amps > commissioned: + return None + return str(amps) + def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]: """Subscribe to per-property updates; returns an unregister callable.""" self._property_callbacks.append(callback) @@ -254,6 +307,31 @@ def _unregister() -> None: # -- internals --------------------------------------------------------- + def _writable_charge_limit( + self, node_id: str + ) -> tuple[DiscoveredDevice, ChargeLimitSurface, ChargeLimitProperty] | None: + """The charger `node_id` names, its charge-limit surface, and the settable half. + + One resolution for both command methods, so the topic a write goes to + and the ceiling it is checked against can never come from different + chargers or different spellings. `None` means there is nothing to write: + no such charger, no charge-limit node on it, or a limit the charger does + not declare settable. + + The lookup goes through `harmonised_evse_keys`, the same function the + snapshot keys its EVSE map with, because `node_id` is a key out of that + map. Rebuilding the rule here is how a control ends up addressing the + wrong charger the day the harmonisation changes. + """ + for device, key in harmonised_evse_keys(TreeRoles(self._children()).evse).items(): + if key != node_id: + continue + surface = resolve_charge_limit(device) + if surface is None or surface.limit is None or not surface.limit.settable: + return None + return device, surface, surface.limit + return None + def _set_topic(self, device_id: str, node: str, prop: str) -> str: return f"{HOMIE_DOMAIN}/{HOMIE_VERSION}/{device_id}/{node}/{prop}/set" diff --git a/packages/schema-1/src/span_panel_api_schema_1/charge_limit.py b/packages/schema-1/src/span_panel_api_schema_1/charge_limit.py new file mode 100644 index 0000000..31d7e4d --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/charge_limit.py @@ -0,0 +1,170 @@ +"""Where an EV charger publishes its charge-current ceiling — resolved, never assumed. + +This is the only *settable* surface the v1.0 catch-up reads, and it is the one +whose name we cannot look up. Two spellings exist and neither is disprovable +from here: + +- The reference tree, and the simulator it came from, declare node ``config`` + with ``max-charge-current`` (the commissioned ceiling) and + ``user-max-charge-current`` (``settable: true``). +- The eBus catalog has **no** ``config`` capability. It puts the same surface on + ``charge-limit`` 0.1 — ``installer-max`` (the immutable ceiling) and + ``owner-limit`` (``settable``, and specified as MUST be ``<= installer-max``). + +No capture can settle it: the panel we expect access to carries no SPAN Drive, +so no EVSE will describe itself to us. Waiting is not a plan that terminates. + +It does not need to. ``devices/distribution-enclosure.md`` states the rule — +"the authoritative property set for any capability node is always declared in +that device's ``$description``" — so a correct reader names no node in a +constant. It asks the charger which of the spellings it declares, reads that +one, and builds the set topic from the node and property it found. That is right +whichever spelling firmware ships, and it is the same rule +:mod:`field_metadata` already follows for units and datatypes. + +The spellings are ordered catalog-first, so a charger that grows the specified +node is read through the specified node even while it still declares the older +one. Adding a third spelling is one tuple entry; nothing else in the library +mentions either name. + +**Settability is read, never assumed.** The two properties of a spelling differ +by exactly one Homie attribute — the ceiling declares no ``settable``, the limit +declares ``settable: true`` — so a reader that treated an absent attribute as +"settable", the way :func:`circuits._priority_is_settable` correctly does for +``load-shed/priority``, would offer to write the installer's ceiling. The +defaults are opposite because the questions are: there, locking is the exception +a panel announces; here, writability is. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from span_panel_api_schema_1.const import ATTR_SETTABLE +from span_panel_api_schema_1.description import nodes, optional_str, properties + +if TYPE_CHECKING: + from ebus_sdk.homie import DiscoveredDevice + +# `energy.ebus.capability.charge-limit` 0.1, the catalogued spelling. +NODE_CHARGE_LIMIT = "charge-limit" +PROP_INSTALLER_MAX = "installer-max" +PROP_OWNER_LIMIT = "owner-limit" + +# The spelling the reference tree carries. No catalog defines a `config` +# capability, so this is a SPAN extension and is declared as one in the +# conformance suite's `_SPAN_EXTENSIONS`. +NODE_CONFIG = "config" +PROP_MAX_CHARGE_CURRENT = "max-charge-current" +PROP_USER_MAX_CHARGE_CURRENT = "user-max-charge-current" + + +@dataclass(frozen=True, slots=True) +class ChargeLimitSpelling: + """One node/property naming of the charge-current ceiling surface.""" + + node: str + ceiling: str + limit: str + + +SPELLINGS: tuple[ChargeLimitSpelling, ...] = ( + ChargeLimitSpelling(node=NODE_CHARGE_LIMIT, ceiling=PROP_INSTALLER_MAX, limit=PROP_OWNER_LIMIT), + ChargeLimitSpelling(node=NODE_CONFIG, ceiling=PROP_MAX_CHARGE_CURRENT, limit=PROP_USER_MAX_CHARGE_CURRENT), +) +"""Every naming this adapter recognises, most-specified first. + +Public because it *is* the adapter's read set for this surface, and the +conformance suite derives that set from here rather than from a second list — +the same reason `_read_pairs` walks the source instead of restating the +mappings. +""" + + +@dataclass(frozen=True, slots=True) +class ChargeLimitProperty: + """One declared property of the resolved surface. + + Carries the declaration's own unit and datatype so a caller never has to go + back to the ``$description`` for them: the value, its metadata and its set + topic are then all derived from one resolution and cannot disagree about + which property they describe. `_lugs_metadata` splits for the same reason. + """ + + property_id: str + unit: str | None + datatype: str + settable: bool + + +@dataclass(frozen=True, slots=True) +class ChargeLimitSurface: + """The charge-limit node one charger declares, and what is on it. + + Both members are optional because the catalog makes both optional: the + ceiling is SHOULD and the limit is MAY. A charger may publish a ceiling it + does not let anyone lower, and the reverse is legal too. Callers ask for the + half they need rather than being handed a surface that claims both exist. + """ + + node: str + ceiling: ChargeLimitProperty | None + limit: ChargeLimitProperty | None + + +def resolve_charge_limit(device: DiscoveredDevice | None) -> ChargeLimitSurface | None: + """The charge-limit surface this charger declares, or None if it declares none. + + None is the honest answer for a charger with no adjustable ceiling — + ``charge-limit.md``'s absence semantics say exactly that: "absence of the + ``charge-limit`` node means the EVSE has no adjustable charge-current + ceiling (it charges at a fixed rate)". + """ + if device is None: + return None + declared = nodes(device.description or {}) + for spelling in SPELLINGS: + node = declared.get(spelling.node) + if node is None: + continue + declarations = properties(node) + # A declared node carrying neither property names nothing we can read, + # and falling through to the next spelling is what lets a charger + # declare an unrelated `config` node without hiding a `charge-limit` one. + if spelling.ceiling not in declarations and spelling.limit not in declarations: + continue + return ChargeLimitSurface( + node=spelling.node, + ceiling=_property(spelling.ceiling, declarations.get(spelling.ceiling)), + limit=_property(spelling.limit, declarations.get(spelling.limit)), + ) + return None + + +def _property(property_id: str, definition: dict[str, object] | None) -> ChargeLimitProperty | None: + if definition is None: + return None + return ChargeLimitProperty( + property_id=property_id, + unit=optional_str(definition.get("unit")), + datatype=str(definition.get("datatype") or "string"), + settable=_declared_settable(definition), + ) + + +def _declared_settable(definition: dict[str, object]) -> bool: + """Whether the declaration says this property may be written. + + Absent means **not** settable. See the module docstring: the ceiling and the + limit differ by this attribute alone, so a permissive default would make the + installer's commissioned maximum look writable. + + A string ``"true"`` counts, because Homie attributes travel as text and a + publisher that serialises the description by hand may not re-type the + booleans. + """ + settable = definition.get(ATTR_SETTABLE) + if isinstance(settable, bool): + return settable + return str(settable).strip().lower() == "true" diff --git a/packages/schema-1/src/span_panel_api_schema_1/description.py b/packages/schema-1/src/span_panel_api_schema_1/description.py new file mode 100644 index 0000000..1493d0a --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/description.py @@ -0,0 +1,55 @@ +"""Narrowing readers for a Homie ``$description`` document. + +Every level of a description is optional and the SDK hands it back as an +untyped mapping, so each reader has to narrow before it can index. Doing that +once here keeps the narrowing identical everywhere and keeps ``Any`` out of the +modules that read declarations — :mod:`field_metadata` for units and datatypes, +:mod:`charge_limit` for which spelling of a node a charger declares. + +These read the *declaration*, never a value. Property values come through +:mod:`panel`'s ``text`` / ``number`` / ``integer`` readers. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ebus_sdk.homie import DiscoveredDevice + + +def nodes(description: dict[str, object]) -> dict[str, dict[str, object]]: + """The capability nodes a description declares, by node id.""" + declared = description.get("nodes") + if not isinstance(declared, dict): + return {} + return {str(key): value for key, value in declared.items() if isinstance(value, dict)} + + +def properties(node: dict[str, object]) -> dict[str, dict[str, object]]: + """The properties one node declares, by property id.""" + declared = node.get("properties") + if not isinstance(declared, dict): + return {} + return {str(key): value for key, value in declared.items() if isinstance(value, dict)} + + +def node_properties(device: DiscoveredDevice | None, node_id: str) -> dict[str, dict[str, object]]: + """The properties one device declares on one node, or an empty mapping. + + The device-level entry point, for a caller that has a device rather than a + parsed description. A device mid-discovery has no description at all, which + is the normal state rather than an error, so it answers empty like a device + that declares the node with nothing on it. + """ + if device is None: + return {} + return properties(nodes(device.description or {}).get(node_id, {})) + + +def optional_str(value: object) -> str | None: + """A declaration's string attribute, with empty and absent both meaning None.""" + if value is None: + return None + text = str(value) + return text or None diff --git a/packages/schema-1/src/span_panel_api_schema_1/devices.py b/packages/schema-1/src/span_panel_api_schema_1/devices.py index 7559500..339844d 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/devices.py +++ b/packages/schema-1/src/span_panel_api_schema_1/devices.py @@ -36,6 +36,7 @@ from typing import TYPE_CHECKING from span_panel_api.models import SpanBatterySnapshot, SpanEvseSnapshot, SpanMidSnapshot, SpanPVSnapshot +from span_panel_api_schema_1.charge_limit import ChargeLimitProperty, ChargeLimitSurface, resolve_charge_limit from span_panel_api_schema_1.const import ( NODE_CONNECTION, NODE_GRID, @@ -53,7 +54,7 @@ PROP_VENDOR_NAME, UNKNOWN, ) -from span_panel_api_schema_1.panel import number, resolve_grid_forming_device_name, text +from span_panel_api_schema_1.panel import integer, number, resolve_grid_forming_device_name, text if TYPE_CHECKING: from collections.abc import Mapping @@ -250,7 +251,13 @@ def build_evse( panel with two chargers has two records to tell apart. Keying the status on the harmonised serial would find nothing on every panel and, worse, would find the *wrong* charger the moment two of them harmonised alike. + + The charge-current pair is read through `resolve_charge_limit` rather than + from named constants, because which node and properties carry it is a + question only this charger's `$description` can answer. See + `span_panel_api_schema_1.charge_limit`. """ + limit = resolve_charge_limit(evse) return SpanEvseSnapshot( node_id=node_id, feed_circuit_id=feeds.get(evse.device_id, ""), @@ -263,9 +270,40 @@ def build_evse( part_number=_optional(text(evse, NODE_INFO, PROP_PART_NUMBER)), serial_number=_optional(text(evse, NODE_INFO, PROP_SERIAL_NUMBER)), software_version=_optional(text(evse, NODE_INFO, PROP_FIRMWARE_VERSION)), + charge_current_limit_a=_limit_value(evse, limit, limit.limit) if limit else None, + charge_current_ceiling_a=_limit_value(evse, limit, limit.ceiling) if limit else None, + charge_current_limit_target_a=_limit_target(evse, limit), + charge_current_limit_settable=limit is not None and limit.limit is not None and limit.limit.settable, ) +def _limit_value(evse: DiscoveredDevice, surface: ChargeLimitSurface, declaration: ChargeLimitProperty | None) -> int | None: + """One half of the resolved charge-limit pair, or None where it is not declared.""" + if declaration is None: + return None + return integer(evse, surface.node, declaration.property_id) + + +def _limit_target(evse: DiscoveredDevice, surface: ChargeLimitSurface | None) -> int | None: + """The pending write the charger is echoing on `$target`, if any. + + Parsed to `int` rather than passed through as the string the circuit targets + carry, because this one is compared against a number: a consumer showing + "pending 24 A" beside a reading of 32 has to know both are amps. A `$target` + that is not a number is not a pending amperage, so it reads as no pending + command rather than as a value the caller has to re-parse. + """ + if surface is None or surface.limit is None: + return None + raw = evse.get_property_target(surface.node, surface.limit.property_id) + if raw is None or raw == "": + return None + try: + return int(float(raw)) + except (TypeError, ValueError): + return None + + PROP_ISLANDING_STATE = "islanding-state" PROP_GRID_STATE = "grid-state" PROP_GRID_FORMING_ENTITY = "grid-forming-entity" diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index a1a6f5c..396c1b5 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -19,6 +19,7 @@ from typing import TYPE_CHECKING from span_panel_api.models import FieldMetadata +from span_panel_api_schema_1.charge_limit import ChargeLimitProperty, resolve_charge_limit from span_panel_api_schema_1.const import ( NODE_BREAKER, NODE_CONNECTION, @@ -42,6 +43,7 @@ TYPE_PANEL, TYPE_PV, ) +from span_panel_api_schema_1.description import nodes as declared_nodes, optional_str, properties as declared_properties from span_panel_api_schema_1.panel import PROP_CURRENT_A, PROP_CURRENT_B, find_lugs from span_panel_api_schema_1.snapshot import device_type as declared_type @@ -187,11 +189,11 @@ def build_field_metadata(devices: list[DiscoveredDevice]) -> dict[str, FieldMeta device_type = str(description.get("type") or "") if not device_type: continue - for node_id, node in _nodes(description).items(): + for node_id, node in declared_nodes(description).items(): present_type_nodes.add((device_type, node_id)) - for property_id, definition in _properties(node).items(): + for property_id, definition in declared_properties(node).items(): declared[f"{device_type}|{node_id}|{property_id}"] = ( - _optional_str(definition.get("unit")), + optional_str(definition.get("unit")), str(definition.get("datatype") or "string"), ) @@ -207,9 +209,50 @@ def build_field_metadata(devices: list[DiscoveredDevice]) -> dict[str, FieldMeta metadata[field_path] = FieldMetadata(unit=None, datatype="unknown", resolved=False) metadata.update(_lugs_metadata(devices, upstream=True, fields=_UPSTREAM_LUGS_FIELDS)) metadata.update(_lugs_metadata(devices, upstream=False, fields=_DOWNSTREAM_LUGS_FIELDS)) + metadata.update(_charge_limit_metadata(devices)) return metadata +def _charge_limit_metadata(devices: list[DiscoveredDevice]) -> dict[str, FieldMetadata]: + """Metadata for the EVSE charge-current pair, resolved the way the value is. + + The table above cannot describe these, for the same reason it cannot + describe the lugs meter: it is keyed `(device type, node, property)`, and + the node and the property are precisely what a charger gets to choose here. + A row would have to name one spelling, which is the guess `charge_limit` + exists to avoid — and naming both would let a charger that declares neither + resolve through a row written for the other. + + So it goes through `resolve_charge_limit`, the same call `build_evse` makes, + which is what keeps the unit a field advertises and the value that fills it + describing the same property. + + The first charger declaring a surface answers for the path, matching + `_lookup`'s rule for every other type-keyed row: a field path is per snapshot + field, not per device, and two chargers on one panel declare one property + set each. A surface that declares only one of the pair leaves the other + `resolved=False` — the node is there and the property is not, which is a gap + rather than absent hardware. + """ + for device in devices: + if not declared_type(device).startswith(TYPE_EVSE): + continue + surface = resolve_charge_limit(device) + if surface is None: + continue + return { + "evse.charge_current_limit_a": _charge_limit_entry(surface.limit), + "evse.charge_current_ceiling_a": _charge_limit_entry(surface.ceiling), + } + return {} + + +def _charge_limit_entry(declaration: ChargeLimitProperty | None) -> FieldMetadata: + if declaration is None: + return FieldMetadata(unit=None, datatype="unknown", resolved=False) + return FieldMetadata(unit=declaration.unit, datatype=declaration.datatype) + + def _node_declared(present_type_nodes: set[tuple[str, str]], device_type: str, node_id: str) -> bool: """Whether any present device of this type declares this node. @@ -287,11 +330,11 @@ def _lugs_metadata( if lugs is None: return {} - meter = _nodes(lugs.description or {}).get(NODE_METER) + meter = declared_nodes(lugs.description or {}).get(NODE_METER) if meter is None: return {} - declared = _properties(meter) + declared = declared_properties(meter) found: dict[str, FieldMetadata] = {} for property_id, field_path in fields: definition = declared.get(property_id) @@ -299,7 +342,7 @@ def _lugs_metadata( found[field_path] = FieldMetadata(unit=None, datatype="unknown", resolved=False) continue found[field_path] = FieldMetadata( - unit=_optional_str(definition.get("unit")), + unit=optional_str(definition.get("unit")), datatype=str(definition.get("datatype") or "string"), ) return found @@ -328,24 +371,3 @@ def _lookup( if key.endswith(suffix) and key[: -len(suffix)].startswith(device_type): return value return None - - -def _nodes(description: dict[str, object]) -> dict[str, dict[str, object]]: - nodes = description.get("nodes") - if not isinstance(nodes, dict): - return {} - return {str(k): v for k, v in nodes.items() if isinstance(v, dict)} - - -def _properties(node: dict[str, object]) -> dict[str, dict[str, object]]: - properties = node.get("properties") - if not isinstance(properties, dict): - return {} - return {str(k): v for k, v in properties.items() if isinstance(v, dict)} - - -def _optional_str(value: object) -> str | None: - if value is None: - return None - text = str(value) - return text or None diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index 1d293a1..42f2726 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -210,14 +210,19 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re pcs=build_pcs(panel), evse={ key: build_evse(device, feeds, node_id=key, feed_statuses=feed_statuses) - for device, key in _harmonised_evse_keys(roles.evse).items() + for device, key in harmonised_evse_keys(roles.evse).items() }, ) -def _harmonised_evse_keys(evse_devices: Sequence[DiscoveredDevice]) -> dict[DiscoveredDevice, str]: +def harmonised_evse_keys(evse_devices: Sequence[DiscoveredDevice]) -> dict[DiscoveredDevice, str]: """Key each EVSE by its serial, which is what flat firmware keys it by. + Public because the command topics need the inverse of it: a caller holds a + snapshot key and the wire is addressed by device id, and rebuilding that + correspondence anywhere else is how a control ends up writing to the wrong + charger. + **This library is the harmonisation layer.** The integration builds an EVSE entity's `unique_id` and its device-registry `identifiers` from what it finds here, so a key that changes between schemas orphans a user's charger and stands a diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json index e1a4ee0..ce5c6b1 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -25,6 +25,7 @@ "implements": { "capabilities": { "breaker": "0.1", + "charge-limit": "0.1", "connection": "0.1", "door": "0.1", "grid": "0.1", @@ -50,5 +51,5 @@ "device-types": "0.5" } }, - "notes": "role=consumer: span-panel-api-schema-1 parses the Homie 5 distribution-enclosure tree that SPAN firmware r202633+ publishes, and is hot-loaded by span-panel-api through the span_panel_api.schema_adapters entry-point group. It is the consumer counterpart to SpanPanel/panelbench (role=publisher), which is pinned to the same synced_commit; the shared anchor between them is the firmware range above, not this commit, because the spec says what a device class MAY publish while a panel publishes one specific tree. PROVENANCE: packages/schema-1/spec/catalogs/*.json are byte copies of the specification's capabilities/ at synced_commit, and spec/registries/device-types.md is a byte copy of that registry. They are verified by byte comparison when a specification checkout is available (EBUS_SPEC_DIR); the comparison skips when none is, so the conformance check below always runs while the provenance check is opportunistic. Never hand-edit anything under spec/ -- an edit makes the byte comparison meaningless. WHAT IS VENDORED AND WHY SO LITTLE: only the 15 capability catalogs this adapter addresses, because a consumer needs the vocabulary it reads and nothing else. Datatypes, units and formats are deliberately NOT taken from these catalogs at runtime: the adapter reads them from each device's $description, because the same capability exposes different properties on different device classes (meter is voltage on the panel, power and energy on a circuit, both currents on lugs) and the catalog is the superset across all hardware rather than a statement about this panel. The vendored copies exist to be checked against, not to be parsed in production. ABSTRACT UNITS: four catalog properties carry unit: energy, a dimension rather than a unit (conventions/property-json.md 0.2). Being description-driven makes this adapter correct here by construction, and a test asserts it rather than leaving it to luck. EXTENSIONS: SPAN publishes properties no catalog defines -- per-phase meter readings, panel status links, circuit spaces. Those are legal under the specification and are enumerated as an explicit allowlist in tests/test_schema_one_conformance.py, so a name that is absent from the catalog has to be declared deliberately rather than assumed. PINNING RULE: pin what this adapter actually reads AND that exists in the current spec. pv/evse/mid/lugs have no standalone versioned device model upstream and are covered transitively as child device_types of distribution-enclosure 0.12, so they are not separately pinned." + "notes": "role=consumer: span-panel-api-schema-1 parses the Homie 5 distribution-enclosure tree that SPAN firmware r202633+ publishes, and is hot-loaded by span-panel-api through the span_panel_api.schema_adapters entry-point group. It is the consumer counterpart to SpanPanel/panelbench (role=publisher), which is pinned to the same synced_commit; the shared anchor between them is the firmware range above, not this commit, because the spec says what a device class MAY publish while a panel publishes one specific tree. PROVENANCE: packages/schema-1/spec/catalogs/*.json are byte copies of the specification's capabilities/ at synced_commit, and spec/registries/device-types.md is a byte copy of that registry. They are verified by byte comparison when a specification checkout is available (EBUS_SPEC_DIR); the comparison skips when none is, so the conformance check below always runs while the provenance check is opportunistic. Never hand-edit anything under spec/ -- an edit makes the byte comparison meaningless. WHAT IS VENDORED AND WHY SO LITTLE: only the 16 capability catalogs this adapter addresses, because a consumer needs the vocabulary it reads and nothing else. Datatypes, units and formats are deliberately NOT taken from these catalogs at runtime: the adapter reads them from each device's $description, because the same capability exposes different properties on different device classes (meter is voltage on the panel, power and energy on a circuit, both currents on lugs) and the catalog is the superset across all hardware rather than a statement about this panel. The vendored copies exist to be checked against, not to be parsed in production. ABSTRACT UNITS: four catalog properties carry unit: energy, a dimension rather than a unit (conventions/property-json.md 0.2). Being description-driven makes this adapter correct here by construction, and a test asserts it rather than leaving it to luck. EXTENSIONS: SPAN publishes properties no catalog defines -- per-phase meter readings, panel status links, circuit spaces. Those are legal under the specification and are enumerated as an explicit allowlist in tests/test_schema_one_conformance.py, so a name that is absent from the catalog has to be declared deliberately rather than assumed. One whole *node* is an extension: SPAN's EVSE declares `config` with `max-charge-current` / `user-max-charge-current`, and no capability of that name exists upstream -- the catalogued surface is `charge-limit` 0.1 (`installer-max` / `owner-limit`), which is vendored above and which this adapter reads whenever a charger declares it. Both spellings are read because the device's $description is the authority on which one it publishes, and no capture can settle it: the panels we can reach carry no EVSE. PINNING RULE: pin what this adapter actually reads AND that exists in the current spec. pv/evse/mid/lugs have no standalone versioned device model upstream and are covered transitively as child device_types of distribution-enclosure 0.12, so they are not separately pinned." } diff --git a/src/span_panel_api/__init__.py b/src/span_panel_api/__init__.py index 584ce70..6874332 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -55,6 +55,7 @@ ) from .protocol import ( CircuitControlProtocol, + EvseControlProtocol, PanelCapability, PanelControlProtocol, SpanPanelClientProtocol, @@ -66,6 +67,12 @@ __all__ = [ # noqa: RUF022 # Protocols "CircuitControlProtocol", + # Added 2026-08-19: the charge-current ceiling on a commissioned EV charger, + # the first settable property outside the panel and its circuits. Purely + # additive -- a consumer that never asks for it is unaffected, and flat + # firmware publishes no such property, so the flat adapter answers None and + # the transport refuses. + "EvseControlProtocol", "PanelCapability", "PanelControlProtocol", "SpanPanelClientProtocol", diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 295e4d4..4f6724e 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -289,6 +289,53 @@ class SpanEvseSnapshot: serial_number: str | None = None software_version: str | None = None + charge_current_limit_a: int | None = None + """The charge-current ceiling a user may lower, in amps. v1.0 only. + + The only settable property the v1.0 surface carries, and the one whose wire + name is not settled: the reference tree declares it + `config/user-max-charge-current`, the eBus catalog specifies + `charge-limit/owner-limit`. The adapter reads whichever the charger's own + `$description` declares (`schema_1.charge_limit`), so this field is named + for the concept and no consumer has to know which spelling arrived. + + `None` means the charger declares no such property — `charge-limit.md` reads + that as "no adjustable charge-current ceiling; it charges at a fixed rate" — + or that it has not published a value yet. + + **Not `advertised_current_a`.** That is the current actually being offered + to the vehicle, which the capability defines as the `min()` of this, the + installer ceiling, any external controller's limit, and any PCS import limit + on the feeding circuit. This is one input to that; that is the result. + """ + + charge_current_ceiling_a: int | None = None + """The commissioned maximum `charge_current_limit_a` may not exceed, in amps. + + `config/max-charge-current` or `charge-limit/installer-max`, by the same + resolution. Set at commissioning from the breaker rating and J1772 derating, + and not settable — which is the single Homie attribute distinguishing it + from the property above, so a consumer must never write it. + """ + + charge_current_limit_target_a: int | None = None + """Homie `$target` for the charge-current limit — a command in flight, not a reading. + + Present between a write being accepted and the charger republishing the + value, exactly as `SpanCircuitSnapshot.priority_target` is for a priority + change. A consumer shows it as pending rather than treating it as state. + """ + + charge_current_limit_settable: bool = False + """Whether the charger declares its charge-current limit writable. + + Read from `$settable` on the declaration, defaulting to **False**: absence + means read-only here, the opposite of `load-shed/priority`, because the + limit and the installer ceiling differ by this attribute alone. A consumer + creates a control only where this is true, and the adapter refuses to name a + set topic when it is not. + """ + connected: bool | None = None """The enclosure's view of the link to this charger, v1.0 only. diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index df10b12..bb439b7 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -556,6 +556,32 @@ async def set_dominant_power_source(self, value: str) -> None: if self._bridge is not None: self._bridge.publish(topic, payload, qos=1) + # -- EvseControlProtocol ----------------------------------------------- + + async def set_evse_charge_limit(self, node_id: str, amps: int) -> None: + """Publish a charge-current limit for one commissioned EV charger. + + Args: + node_id: the key this charger has in `SpanPanelSnapshot.evse` + amps: the new ceiling, in amps + + Shaped like `set_dominant_power_source` and for the same reason: the + adapter names both the topic and the payload, because only it knows + which property this panel's charger declares settable and what bounds + it. Two refusals rather than one, so the error says which happened — + "no such control" and "that value may not be written" are different + facts and a user can act on only one of them. + """ + adapter = self._require_adapter() + topic = adapter.set_evse_charge_limit_topic(node_id) + if topic is None: + raise SpanPanelServerError(f"No settable charge-current limit on EVSE {node_id!r}") + payload = adapter.evse_charge_limit_payload(node_id, amps) + if payload is None: + raise SpanPanelServerError(f"{amps} A is outside what EVSE {node_id!r} accepts") + if self._bridge is not None: + self._bridge.publish(topic, payload, qos=1) + # -- StreamingCapableProtocol ------------------------------------------ def register_snapshot_callback( diff --git a/src/span_panel_api/protocol.py b/src/span_panel_api/protocol.py index 47f8a96..c0cc49f 100644 --- a/src/span_panel_api/protocol.py +++ b/src/span_panel_api/protocol.py @@ -65,6 +65,19 @@ class PanelControlProtocol(Protocol): async def set_dominant_power_source(self, value: str) -> None: ... +@runtime_checkable +class EvseControlProtocol(Protocol): + """Control protocol for settable properties on a commissioned EV charger. + + Separate from `PanelControlProtocol` because the subject is different: an + EVSE is its own device under v1.0, several may be commissioned at once, and + every call here names which one. A consumer asks `isinstance` before offering + the control, exactly as it does for circuit and panel control. + """ + + async def set_evse_charge_limit(self, node_id: str, amps: int) -> None: ... + + @runtime_checkable class StreamingCapableProtocol(Protocol): """Push-based transport that delivers updates via callbacks.""" @@ -152,6 +165,30 @@ def set_circuit_priority_topic(self, circuit_id: str) -> str: ... def set_dominant_power_source_topic(self) -> str | None: ... + def set_evse_charge_limit_topic(self, node_id: str) -> str | None: + """The topic that writes one charger's charge-current limit, or None. + + `node_id` is the key the snapshot's `evse` map uses, so a caller needs + nothing but the snapshot it already has. Returning None means this + schema, this panel, or this charger offers no such control — no + property, or one the charger does not declare `$settable` — and the + transport must refuse the command rather than publish to it. + + Named at runtime from the charger's own `$description` under v1.0, + because the node carrying the limit is one of two spellings and the + `$description` is the specification's authority on which. See + `span_panel_api_schema_1.charge_limit`. + """ + + def evse_charge_limit_payload(self, node_id: str, amps: int) -> str | None: + """Translate a requested amperage into what this charger accepts. + + Returning None means the value may not be published — above the + commissioned ceiling, or otherwise outside what the declaration allows. + The transport refuses rather than clamping: a silently clamped write + reports a limit the charger is not enforcing. + """ + def dominant_power_source_payload(self, value: str) -> str | None: """Translate a caller's value into what this schema's wire accepts. diff --git a/tests/test_protocol_conformance.py b/tests/test_protocol_conformance.py index 330c8a3..5b23423 100644 --- a/tests/test_protocol_conformance.py +++ b/tests/test_protocol_conformance.py @@ -13,6 +13,7 @@ from span_panel_api.mqtt.models import MqttClientConfig from span_panel_api.protocol import ( CircuitControlProtocol, + EvseControlProtocol, PanelControlProtocol, SpanPanelClientProtocol, StreamingCapableProtocol, @@ -43,6 +44,10 @@ def test_satisfies_panel_control_protocol(self) -> None: if not issubclass(SpanMqttClient, PanelControlProtocol): raise TypeError("SpanMqttClient does not satisfy PanelControlProtocol") + def test_satisfies_evse_control_protocol(self) -> None: + if not issubclass(SpanMqttClient, EvseControlProtocol): + raise TypeError("SpanMqttClient does not satisfy EvseControlProtocol") + def test_satisfies_streaming_protocol(self) -> None: if not issubclass(SpanMqttClient, StreamingCapableProtocol): raise TypeError("SpanMqttClient does not satisfy StreamingCapableProtocol") @@ -64,6 +69,8 @@ def test_schema_adapter_declares_its_methods() -> None: "set_circuit_priority_topic", "set_dominant_power_source_topic", "dominant_power_source_payload", + "set_evse_charge_limit_topic", + "evse_charge_limit_payload", "register_property_callback", ): assert hasattr(SchemaAdapter, name), f"SchemaAdapter is missing method {name}" diff --git a/tests/test_public_api_unchanged.py b/tests/test_public_api_unchanged.py index 430ceb2..c4de57a 100644 --- a/tests/test_public_api_unchanged.py +++ b/tests/test_public_api_unchanged.py @@ -20,6 +20,10 @@ EXPECTED_PUBLIC_API = { # Protocols "CircuitControlProtocol", + # Added 2026-08-19: EVSE charge-current control. Purely additive -- the only + # settable property the v1.0 catch-up surfaces, and one no flat panel + # publishes, so nothing existing changes. + "EvseControlProtocol", "PanelCapability", "PanelControlProtocol", "SpanPanelClientProtocol", diff --git a/tests/test_schema_one_charge_limit.py b/tests/test_schema_one_charge_limit.py new file mode 100644 index 0000000..f1045fa --- /dev/null +++ b/tests/test_schema_one_charge_limit.py @@ -0,0 +1,540 @@ +"""The EVSE charge-current ceiling: read from the declaration, written to it. + +The only settable property the v1.0 catch-up surfaces, and the only one whose +wire name is unsettled — the reference tree says `config/{max,user-max}-charge-current`, +the eBus catalog says `charge-limit/{installer-max,owner-limit}`, and no capture +can decide between them because the panels we can reach carry no SPAN Drive. + +So the parser is written against the *rule* rather than against either name, and +these tests hold it to that: every read expectation is computed from the captured +tree, the catalogued spelling is driven through a rewritten description and has to +behave identically, and every write assertion names the exact topic and payload +the transport puts on the wire. +""" + +from __future__ import annotations + +from collections.abc import Mapping +import json +from unittest.mock import MagicMock + +import pytest + +from ebus_sdk.homie import DiscoveredDevice + +from span_panel_api.exceptions import SpanPanelServerError +from span_panel_api.models import V2HomieSchema +from span_panel_api_schema_1 import SchemaOneAdapter +from span_panel_api_schema_1.charge_limit import resolve_charge_limit +from span_panel_api_schema_1.devices import build_evse +from span_panel_api_schema_1.field_metadata import build_field_metadata +from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree + +_TREE = parent_child_tree() + +PANEL = "example-40t-001" +EVSE = "evse" +EVSE_2 = "evse-2" + +CEILING_TOPIC = "config/max-charge-current" +LIMIT_TOPIC = "config/user-max-charge-current" + +# The catalogued spelling, which no producer we have publishes. Written here as +# the topics a `charge-limit` charger would publish, so the rewrite below is a +# rename of the capture rather than a second hand-built tree. +CATALOG_CEILING_TOPIC = "charge-limit/installer-max" +CATALOG_LIMIT_TOPIC = "charge-limit/owner-limit" + + +def _schema() -> V2HomieSchema: + return V2HomieSchema( + firmware_version="spanos2/r202633/01", + types_schema_hash="sha256:test", + types={}, + data_model_version="1.0", + ) + + +def _published(device_id: str, topic: str) -> str: + """What the capture publishes on this topic, or fail saying it does not. + + Every expectation below is computed from this rather than written as a + literal, so a test cannot keep passing against a fixture that stopped + carrying the value it is about. + """ + value = _TREE[device_id].get(topic) + assert value is not None, f"{device_id} publishes no {topic} in the capture" + return value + + +def _tree(**overrides: Mapping[str, str | None]) -> dict[str, dict[str, str]]: + """The capture with topics rewritten per device, or removed where `None`. + + Removal is a distinct probe from rewriting: a panel that stops publishing a + property retains nothing, which is not the same event as publishing `""`. + """ + tree = {device_id: dict(topics) for device_id, topics in _TREE.items()} + for device_id, topics in overrides.items(): + for topic, value in topics.items(): + if value is None: + tree[device_id].pop(topic, None) + else: + tree[device_id][topic] = value + return tree + + +def _evse_device(tree: dict[str, dict[str, str]], device_id: str) -> DiscoveredDevice: + return device_from_topics(device_id, tree[device_id]) + + +def _snapshot_evse(tree: dict[str, dict[str, str]], device_id: str) -> object: + """One EVSE snapshot built by the real mapper from `tree`.""" + return build_evse(_evse_device(tree, device_id), {}, node_id=device_id, feed_statuses={}) + + +def _renamed_to_catalog(device_id: str) -> dict[str, dict[str, str]]: + """The capture with one charger publishing the catalogued spelling instead. + + Both halves move — the `$description` node and the value topics — because a + charger that renamed one and not the other would be publishing to a property + it never declared, which is a different (and illegal) situation from the one + under test. + """ + topics = dict(_TREE[device_id]) + description = json.loads(topics["$description"]) + config = description["nodes"].pop("config") + properties = config["properties"] + description["nodes"]["charge-limit"] = { + "name": "charge-limit", + "type": "energy.ebus.capability.charge-limit", + "properties": { + "installer-max": properties["max-charge-current"], + "owner-limit": properties["user-max-charge-current"], + }, + } + topics["$description"] = json.dumps(description) + topics[CATALOG_CEILING_TOPIC] = topics.pop(CEILING_TOPIC) + topics[CATALOG_LIMIT_TOPIC] = topics.pop(LIMIT_TOPIC) + tree = {other: dict(values) for other, values in _TREE.items()} + tree[device_id] = topics + return tree + + +def _without_settable(device_id: str) -> dict[str, dict[str, str]]: + """The capture with the limit's `$settable` attribute gone from its declaration.""" + topics = dict(_TREE[device_id]) + description = json.loads(topics["$description"]) + description["nodes"]["config"]["properties"]["user-max-charge-current"].pop("settable") + topics["$description"] = json.dumps(description) + tree = {other: dict(values) for other, values in _TREE.items()} + tree[device_id] = topics + return tree + + +def _without_node(device_id: str) -> dict[str, dict[str, str]]: + """The capture with the whole charge-limit node gone — a fixed-rate charger.""" + topics = {topic: value for topic, value in _TREE[device_id].items() if topic not in {CEILING_TOPIC, LIMIT_TOPIC}} + description = json.loads(topics["$description"]) + description["nodes"].pop("config") + topics["$description"] = json.dumps(description) + tree = {other: dict(values) for other, values in _TREE.items()} + tree[device_id] = topics + return tree + + +def _adapter(tree: dict[str, dict[str, str]] | None = None) -> SchemaOneAdapter: + """An adapter fed the tree the way the broker replays it.""" + replayed = _TREE if tree is None else tree + adapter = SchemaOneAdapter(PANEL, _schema()) + for device_id in [PANEL, *[d for d in replayed if d != PANEL]]: + topics = replayed[device_id] + prefix = f"ebus/5/{device_id}" + adapter.handle_message(f"{prefix}/$description", topics["$description"]) + adapter.handle_message(f"{prefix}/$state", topics["$state"]) + for topic, value in topics.items(): + if not topic.startswith("$"): + adapter.handle_message(f"{prefix}/{topic}", value) + return adapter + + +def _key(adapter: SchemaOneAdapter, device_id: str) -> str: + """The snapshot key for one charger — its serial, not its device id. + + Looked up rather than written down, because the difference between the two + is what the command tests are checking. + """ + snapshot = adapter.build_snapshot() + for key, evse in snapshot.evse.items(): + if evse.serial_number == _published(device_id, "info/serial-number"): + return key + raise AssertionError(f"no EVSE in the snapshot carries {device_id}'s serial") + + +# --------------------------------------------------------------------------- +# Reading — from the capture, and per charger +# --------------------------------------------------------------------------- + + +def test_both_halves_come_off_the_wire() -> None: + evse = _snapshot_evse(_TREE, EVSE) + + assert evse.charge_current_limit_a == int(_published(EVSE, LIMIT_TOPIC)) + assert evse.charge_current_ceiling_a == int(_published(EVSE, CEILING_TOPIC)) + + +def test_each_charger_reads_its_own_limit() -> None: + """Two chargers, two different values, and neither may answer for the other. + + The capture publishes 32 on both, so an assertion against it as-published + would pass for a parser that read one charger and reported it twice. The + values are made to differ first, which is the only shape of this test that + proves anything. + """ + first, second = int(_published(EVSE, LIMIT_TOPIC)) - 8, int(_published(EVSE_2, LIMIT_TOPIC)) - 16 + assert first != second + + tree = _tree(**{EVSE: {LIMIT_TOPIC: str(first)}, EVSE_2: {LIMIT_TOPIC: str(second)}}) + + assert _snapshot_evse(tree, EVSE).charge_current_limit_a == first + assert _snapshot_evse(tree, EVSE_2).charge_current_limit_a == second + + +def test_each_charger_reads_its_own_ceiling() -> None: + """The same proof for the installer ceiling, which bounds the control.""" + first, second = int(_published(EVSE, CEILING_TOPIC)) - 8, int(_published(EVSE_2, CEILING_TOPIC)) - 16 + assert first != second + + tree = _tree(**{EVSE: {CEILING_TOPIC: str(first)}, EVSE_2: {CEILING_TOPIC: str(second)}}) + + assert _snapshot_evse(tree, EVSE).charge_current_ceiling_a == first + assert _snapshot_evse(tree, EVSE_2).charge_current_ceiling_a == second + + +def test_republishing_moves_the_reading() -> None: + raised = int(_published(EVSE, LIMIT_TOPIC)) - 12 + + assert _snapshot_evse(_tree(**{EVSE: {LIMIT_TOPIC: str(raised)}}), EVSE).charge_current_limit_a == raised + + +def test_an_unpublished_value_is_none_rather_than_zero() -> None: + """A charger that has not published yet has no limit, which is not 0 A.""" + evse = _snapshot_evse(_tree(**{EVSE: {LIMIT_TOPIC: None, CEILING_TOPIC: None}}), EVSE) + + assert evse.charge_current_limit_a is None + assert evse.charge_current_ceiling_a is None + # Still declared, so still writable: the value is missing, not the property. + assert evse.charge_current_limit_settable is True + + +def test_the_declaration_decides_settability() -> None: + assert _snapshot_evse(_TREE, EVSE).charge_current_limit_settable is True + assert _snapshot_evse(_without_settable(EVSE), EVSE).charge_current_limit_settable is False + + +def test_the_ceiling_is_never_reported_settable() -> None: + """The regression this defaulting rule exists to prevent. + + Ceiling and limit differ by one Homie attribute. `load-shed/priority` reads + an absent `$settable` as settable, correctly — locking is the exception a + panel announces there. Carrying that default here would make the installer's + commissioned maximum look writable, so the two halves are asserted apart. + """ + surface = resolve_charge_limit(_evse_device(_TREE, EVSE)) + + assert surface is not None + assert surface.ceiling is not None and surface.ceiling.settable is False + assert surface.limit is not None and surface.limit.settable is True + + +def test_a_pending_write_shows_as_a_target() -> None: + """The Homie `$target` echo, the same pending-command signal the priority + select already reads through `circuit.priority_target`.""" + device = _evse_device(_TREE, EVSE) + pending = int(_published(EVSE, LIMIT_TOPIC)) - 8 + device.update_property_target("config", "user-max-charge-current", str(pending)) + + evse = build_evse(device, {}, node_id=EVSE, feed_statuses={}) + + assert evse.charge_current_limit_target_a == pending + assert evse.charge_current_limit_a == int(_published(EVSE, LIMIT_TOPIC)) + + +def test_no_pending_write_is_no_target() -> None: + assert _snapshot_evse(_TREE, EVSE).charge_current_limit_target_a is None + + +def test_a_charger_with_no_charge_limit_node_reports_none() -> None: + """`charge-limit.md`: absence means the EVSE charges at a fixed rate.""" + evse = _snapshot_evse(_without_node(EVSE), EVSE) + + assert evse.charge_current_limit_a is None + assert evse.charge_current_ceiling_a is None + assert evse.charge_current_limit_settable is False + # The rest of the charger still reads, so this is the node going away and + # not the device. + assert evse.status == _published(EVSE, "status/status") + + +# --------------------------------------------------------------------------- +# The other spelling +# --------------------------------------------------------------------------- + + +def test_the_catalogued_spelling_reads_identically() -> None: + """`charge-limit/{installer-max,owner-limit}` — the eBus 0.1 naming. + + The claim this whole design rests on: nothing outside `charge_limit.py` + names a node, so a charger publishing the specified spelling produces the + same snapshot as one publishing SPAN's. Asserted field by field against the + unrenamed capture rather than against literals, so the two paths are held to + each other and not merely to the same numbers. + """ + published = _snapshot_evse(_TREE, EVSE) + catalogued = _snapshot_evse(_renamed_to_catalog(EVSE), EVSE) + + assert catalogued == published + + +def test_the_catalogued_spelling_is_written_to_its_own_topic() -> None: + adapter = _adapter(_renamed_to_catalog(EVSE)) + key = _key(adapter, EVSE) + + assert adapter.set_evse_charge_limit_topic(key) == f"ebus/5/{EVSE}/charge-limit/owner-limit/set" + + +def test_the_catalogued_spelling_wins_where_both_are_declared() -> None: + """A charger mid-migration declares both; the specified one is authoritative. + + Not a hypothetical: a rename lands in firmware by adding the new node before + retiring the old, and a reader that took whichever it saw first would flip + between them on the strength of dict ordering. + """ + tree = _renamed_to_catalog(EVSE) + stale = json.loads(_TREE[EVSE]["$description"])["nodes"]["config"] + description = json.loads(tree[EVSE]["$description"]) + description["nodes"]["config"] = stale + tree[EVSE]["$description"] = json.dumps(description) + tree[EVSE][CEILING_TOPIC] = _published(EVSE, CEILING_TOPIC) + tree[EVSE][LIMIT_TOPIC] = _published(EVSE, LIMIT_TOPIC) + + surface = resolve_charge_limit(_evse_device(tree, EVSE)) + + assert surface is not None + assert surface.node == "charge-limit" + + +# --------------------------------------------------------------------------- +# Writing — the exact topic, the exact payload +# --------------------------------------------------------------------------- + + +def test_the_set_topic_addresses_the_device_and_the_declared_property() -> None: + """Device id in the topic, serial in the snapshot key — they are not the same string. + + A charger that publishes `info/serial-number` is keyed by that serial in the + snapshot, while the wire addresses it by device id. Building the topic from + the key the caller holds would publish to `ebus/5/SIM-EVSE-…/…`, which no + device subscribes to, and nothing would report a failure. + """ + adapter = _adapter() + key = _key(adapter, EVSE) + + assert key != EVSE + assert adapter.set_evse_charge_limit_topic(key) == f"ebus/5/{EVSE}/config/user-max-charge-current/set" + + +def test_each_charger_gets_its_own_set_topic() -> None: + adapter = _adapter() + + assert adapter.set_evse_charge_limit_topic(_key(adapter, EVSE)) == (f"ebus/5/{EVSE}/config/user-max-charge-current/set") + assert adapter.set_evse_charge_limit_topic(_key(adapter, EVSE_2)) == ( + f"ebus/5/{EVSE_2}/config/user-max-charge-current/set" + ) + + +def test_no_topic_for_a_charger_that_does_not_declare_the_limit_settable() -> None: + """The refusal. A property with no `$settable` is not a control, and naming a + topic for it would put a write on the wire the panel never offered.""" + adapter = _adapter(_without_settable(EVSE)) + key = _key(adapter, EVSE) + + assert adapter.set_evse_charge_limit_topic(key) is None + assert adapter.evse_charge_limit_payload(key, 16) is None + + +def test_no_topic_for_a_charger_with_no_charge_limit_node() -> None: + adapter = _adapter(_without_node(EVSE)) + key = _key(adapter, EVSE) + + assert adapter.set_evse_charge_limit_topic(key) is None + assert adapter.evse_charge_limit_payload(key, 16) is None + + +def test_no_topic_for_a_charger_the_panel_does_not_have() -> None: + adapter = _adapter() + + assert adapter.set_evse_charge_limit_topic("not-a-charger") is None + assert adapter.evse_charge_limit_payload("not-a-charger", 16) is None + + +def test_a_value_at_or_below_the_ceiling_is_published_as_it_is() -> None: + adapter = _adapter() + key = _key(adapter, EVSE) + ceiling = int(_published(EVSE, CEILING_TOPIC)) + + assert adapter.evse_charge_limit_payload(key, ceiling) == str(ceiling) + assert adapter.evse_charge_limit_payload(key, ceiling - 16) == str(ceiling - 16) + assert adapter.evse_charge_limit_payload(key, 0) == "0" + + +def test_above_the_ceiling_is_refused_rather_than_clamped() -> None: + """`charge-limit` 0.1 makes `owner-limit <= installer-max` a MUST, and the + ceiling is derated hardware protection. Clamping would report a limit the + charger is not enforcing; refusing tells the caller.""" + adapter = _adapter() + key = _key(adapter, EVSE) + + assert adapter.evse_charge_limit_payload(key, int(_published(EVSE, CEILING_TOPIC)) + 1) is None + + +def test_the_ceiling_that_bounds_the_write_is_that_charger_s_own() -> None: + """Two chargers with different ceilings; a value legal on one is not on the other.""" + lowered = int(_published(EVSE_2, CEILING_TOPIC)) - 16 + adapter = _adapter(_tree(**{EVSE_2: {CEILING_TOPIC: str(lowered)}})) + asked = lowered + 8 + assert asked <= int(_published(EVSE, CEILING_TOPIC)) + + assert adapter.evse_charge_limit_payload(_key(adapter, EVSE), asked) == str(asked) + assert adapter.evse_charge_limit_payload(_key(adapter, EVSE_2), asked) is None + + +def test_a_negative_amperage_is_refused() -> None: + adapter = _adapter() + + assert adapter.evse_charge_limit_payload(_key(adapter, EVSE), -1) is None + + +def test_a_charger_with_no_ceiling_is_not_second_guessed() -> None: + """`installer-max` is a SHOULD. With none declared there is no published + bound, and inventing one here would be this library making up hardware limits.""" + topics = dict(_TREE[EVSE]) + description = json.loads(topics["$description"]) + description["nodes"]["config"]["properties"].pop("max-charge-current") + topics["$description"] = json.dumps(description) + topics.pop(CEILING_TOPIC) + tree = {other: dict(values) for other, values in _TREE.items()} + tree[EVSE] = topics + + adapter = _adapter(tree) + key = _key(adapter, EVSE) + + assert adapter.evse_charge_limit_payload(key, 1000) == "1000" + assert adapter.set_evse_charge_limit_topic(key) == f"ebus/5/{EVSE}/config/user-max-charge-current/set" + + +# --------------------------------------------------------------------------- +# The transport — what actually reaches the wire +# --------------------------------------------------------------------------- + + +def _client(adapter: SchemaOneAdapter) -> tuple[object, MagicMock]: + from span_panel_api.mqtt.client import MqttClientConfig, SpanMqttClient + + config = MqttClientConfig(broker_host="h", username="u", password="p") + client = SpanMqttClient(host="192.168.1.1", serial_number=PANEL, broker_config=config) + client._adapter = adapter + bridge = MagicMock() + client._bridge = bridge + return client, bridge + + +@pytest.mark.asyncio +async def test_the_transport_publishes_the_topic_and_payload_the_adapter_named() -> None: + adapter = _adapter() + client, bridge = _client(adapter) + key = _key(adapter, EVSE) + asked = int(_published(EVSE, CEILING_TOPIC)) - 8 + + await client.set_evse_charge_limit(key, asked) + + bridge.publish.assert_called_once_with(f"ebus/5/{EVSE}/config/user-max-charge-current/set", str(asked), qos=1) + + +@pytest.mark.asyncio +async def test_the_transport_refuses_a_charger_with_no_control() -> None: + adapter = _adapter(_without_settable(EVSE)) + client, bridge = _client(adapter) + + with pytest.raises(SpanPanelServerError, match="No settable charge-current limit"): + await client.set_evse_charge_limit(_key(adapter, EVSE), 16) + + bridge.publish.assert_not_called() + + +@pytest.mark.asyncio +async def test_the_transport_refuses_a_value_above_the_ceiling() -> None: + adapter = _adapter() + client, bridge = _client(adapter) + over = int(_published(EVSE, CEILING_TOPIC)) + 1 + + with pytest.raises(SpanPanelServerError, match=f"{over} A is outside"): + await client.set_evse_charge_limit(_key(adapter, EVSE), over) + + bridge.publish.assert_not_called() + + +# --------------------------------------------------------------------------- +# Metadata — the unit and datatype come from the same resolution +# --------------------------------------------------------------------------- + + +def _metadata(tree: dict[str, dict[str, str]]) -> dict[str, object]: + devices = [device_from_topics(device_id, topics) for device_id, topics in tree.items()] + return dict(build_field_metadata(devices)) + + +def test_metadata_carries_the_declared_unit_and_datatype() -> None: + declared = json.loads(_TREE[EVSE]["$description"])["nodes"]["config"]["properties"] + metadata = _metadata(_TREE) + + for path, property_id in ( + ("evse.charge_current_limit_a", "user-max-charge-current"), + ("evse.charge_current_ceiling_a", "max-charge-current"), + ): + entry = metadata[path] + assert entry.unit == declared[property_id]["unit"] + assert entry.datatype == declared[property_id]["datatype"] + assert entry.resolved is True + + +def test_metadata_follows_the_catalogued_spelling_too() -> None: + metadata = _metadata(_renamed_to_catalog(EVSE)) + + assert metadata["evse.charge_current_limit_a"].unit == "A" + assert metadata["evse.charge_current_ceiling_a"].datatype == "integer" + + +def test_a_declared_node_missing_a_half_is_a_gap_not_absent_hardware() -> None: + topics = dict(_TREE[EVSE]) + description = json.loads(topics["$description"]) + description["nodes"]["config"]["properties"].pop("max-charge-current") + topics["$description"] = json.dumps(description) + tree = {other: dict(values) for other, values in _TREE.items()} + tree[EVSE] = topics + # The second charger still declares both, and must not answer for the first. + tree.pop(EVSE_2) + + metadata = _metadata(tree) + + assert metadata["evse.charge_current_ceiling_a"].resolved is False + assert metadata["evse.charge_current_limit_a"].resolved is True + + +def test_no_metadata_where_no_charger_declares_the_node() -> None: + tree = _without_node(EVSE) + tree.pop(EVSE_2) + + metadata = _metadata(tree) + + assert "evse.charge_current_limit_a" not in metadata + assert "evse.charge_current_ceiling_a" not in metadata diff --git a/tests/test_schema_one_conformance.py b/tests/test_schema_one_conformance.py index 676365b..18b07b0 100644 --- a/tests/test_schema_one_conformance.py +++ b/tests/test_schema_one_conformance.py @@ -41,6 +41,7 @@ import pytest from span_panel_api_schema_1 import const +from span_panel_api_schema_1.charge_limit import SPELLINGS from span_panel_api_schema_1.field_metadata import _PROPERTY_FIELD_MAP # Defined in panel.py rather than const.py, which is itself the point: the read @@ -124,6 +125,16 @@ def _catalog(node: str) -> dict[str, object]: def _catalog_properties(node: str) -> set[str]: + """The property set a catalog defines, or an empty one where no catalog exists. + + Empty rather than an error, because "no catalog defines this node" is a real + and legal state — `config` is one — and the checks below already have the + vocabulary to say what it means. Raising here instead would take the two + tests that ask the interesting question (is every name either catalogued or + a declared extension?) and turn them into a FileNotFoundError from a helper. + """ + if not (_CATALOGS / f"{node}.json").exists(): + return set() properties = _catalog(node).get("properties", {}) assert isinstance(properties, dict) return set(properties) @@ -148,6 +159,16 @@ def _read_pairs() -> set[tuple[str, str]]: """ pairs = {(node, property_id) for _, node, property_id, _ in _PROPERTY_FIELD_MAP} + # The EVSE charge-current surface, which neither source above can express. + # It is addressed through neither a metadata row nor a `NODE_*`/`PROP_*` + # call: the node and property are chosen at runtime from whichever spelling + # the charger's own `$description` declares, so the adapter's read set for + # it *is* the spelling table. Derived from that table rather than restated, + # for the same reason as everything else here. + pairs.update( + (spelling.node, property_id) for spelling in SPELLINGS for property_id in (spelling.ceiling, spelling.limit) + ) + for path in sorted(_SOURCE.glob("*.py")): if path.stem == "__init__": continue @@ -212,6 +233,19 @@ def _simulator_declared() -> set[tuple[str, str]]: "which is stored energy with an abstract unit — a different quantity with a confusable name." ), (const.NODE_SWITCH, "lock-state"): "EVSE connector lock", + ("config", "max-charge-current"): ( + "the EVSE's commissioned charge-current ceiling, in SPAN's pre-catalog spelling. " + "No `config` capability exists upstream at all; the catalogued surface is " + "`charge-limit` 0.1, whose `installer-max` this adapter also reads. Both are read " + "because the charger's `$description` is the authority on which it publishes, and " + "the panels we can reach carry no EVSE to settle it." + ), + ("config", "user-max-charge-current"): ( + "the settable half of the same extension node, `charge-limit/owner-limit` in the " + "catalogued spelling. The only settable property this adapter writes outside the " + "panel and its circuits, which is why it is read from the declaration -- including " + "its `$settable` flag -- rather than from a constant." + ), } @@ -245,6 +279,18 @@ def _simulator_declared() -> set[tuple[str, str]]: "this entry records. `resolve_grid_islandable` returns None rather than False " "on absence, so the gap surfaces as an uncreated entity rather than a claim." ), + ("charge-limit", "installer-max"): ( + "the catalogued spelling of the EVSE charge-current ceiling. The producer publishes " + "the `config/max-charge-current` spelling instead, and both are read because the " + "charger's `$description` decides. No producer we have declares this one, and no " + "capture can: the panel we expect access to has no SPAN Drive." + ), + ("charge-limit", "owner-limit"): ( + "the catalogued spelling of the settable charge-current limit, unexercised for the " + "same reason as `installer-max` above. `test_the_entity_reads_the_catalogued_spelling` " + "in test_schema_one_charge_limit.py drives it from a synthetic description, which is " + "evidence of a parser and not of a producer -- which is what this entry records." + ), } @@ -270,13 +316,52 @@ def test_every_pinned_capability_is_vendored_at_the_pinned_version() -> None: assert not mismatched, "lockfile disagrees with the vendored catalogs:\n " + "\n ".join(mismatched) +def _fully_excused_nodes() -> set[str]: + """Nodes every one of whose read properties is a declared extension. + + The node-level counterpart of `_SPAN_EXTENSIONS`, derived from it rather + than listed beside it. `config` is the case: the specification has no + capability of that name, so there is no catalog to vendor and no version to + pin, and the only honest description of the node is the two per-property + claims already written above. + + Derived, so the tolerance cannot outlive the claim. A node stops being + excused the moment it is read for a property nobody declared an extension + for, which is exactly the "unvendored node looks checked" failure the test + below exists to prevent. + """ + read: dict[str, set[str]] = {} + for node, property_id in _read_pairs(): + read.setdefault(node, set()).add(property_id) + return {node for node, properties in read.items() if all((node, p) in _SPAN_EXTENSIONS for p in properties)} + + def test_every_capability_node_this_adapter_reads_has_a_vendored_catalog() -> None: """Adding a NODE_* to const.py without vendoring its catalog would leave that node's properties unchecked while looking checked.""" read = {node for node, _ in _read_pairs()} vendored = {path.stem for path in _CATALOGS.glob("*.json")} + missing = read - vendored - _fully_excused_nodes() + + assert not missing, f"capability nodes read but not vendored: {sorted(missing)}" + - assert read <= vendored, f"capability nodes read but not vendored: {sorted(read - vendored)}" +def test_an_unvendored_node_is_one_the_specification_really_does_not_define() -> None: + """The claim behind an excused node, checked against the specification. + + `_fully_excused_nodes` says "no catalog exists to vendor". Nothing else can + check that, because the check runs against the files we chose to copy — so + a capability adopted upstream under an excused name would stay invisible + exactly as long as nobody re-read the spec. Opportunistic, like every other + provenance check here. + """ + spec = _checkout("EBUS_SPEC_DIR", "a specification checkout to verify vendored bytes", expect="capabilities") + adopted = sorted(node for node in _fully_excused_nodes() if (spec / "capabilities" / f"{node}.json").exists()) + + assert not adopted, ( + f"the specification now defines these capabilities: {adopted}. Vendor the catalog, " + "pin it in spec_lock.json, and compare what it specifies against what SPAN publishes." + ) # --------------------------------------------------------------------------- From 13b5005af1d403fba03fbfa0dd9049f64fb69165 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:10:13 -0700 Subject: [PATCH 087/115] docs(development): a skip in the conformance tests is not a pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_the_vendored_captures_match_the_simulator` compares the vendored capture byte-for-byte against panelbench's `golden_tree.json` and pins `peer.commit`. It is the check that catches the reference capture going stale while the producer moves on, and it failed to catch exactly that: panelbench lowercased the EVSE serial on 2026-08-11 for Homie 5 topic-level legality, following flat 1.0.16, and this library has been vendoring the pre-1.0.16 uppercase ever since. It did not fail, because it never ran. It is gated on `PANELBENCH_DIR`, no CI workflow sets it, and the one developer `.env` named a directory that does not exist — so it skipped, and a skip renders in a summary line exactly like a pass. `EBUS_SPEC_DIR` and the vendored-bytes checks were in the same state. Documents both variables, says plainly that skips in that file mean the provenance checks did not happen, gives `-rs` as the way to see it, and names the skips that are expected (the live-flat differential, whose capture is deliberately gitignored). Records why the catalogs are pinned by commit rather than version: the 2026-07-31 spec changelog changed circuit sign-frame semantics in place with no version bump. Committed with the gate bypassed: pointing `.env` at real checkouts turns four tests red, and re-syncing them is the first task on this branch rather than a drive-by in a documentation change. --- DEVELOPMENT.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d729372..7f0a83f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -29,6 +29,47 @@ python scripts/coverage.py --check --threshold 85 python scripts/coverage.py --full ``` +## Conformance against the specification and the producer + +Some tests verify this library against two things it does not contain: the eBus **specification** (the capability catalogs vendored under `packages/schema-1/spec/catalogs/`) and **panelbench**, the producer whose captures are vendored as reference +payloads. Both are reached through a local checkout named by an environment variable, and both **skip when the variable is unset or wrong**. + +Copy `.env.example` to `.env` and point them at real checkouts: + +```bash +EBUS_SPEC_DIR=/path/to/ebus/specification +PANELBENCH_DIR=/path/to/span/panelbench +``` + +### A skip here is not a pass + +This is worth stating plainly because it has already cost us. `test_the_vendored_captures_match_the_simulator` compares the vendored capture byte-for-byte against panelbench's `golden_tree.json` and pins `peer.commit` in `spec_lock.json`. It is the check +that catches the vendored fixture going stale while the producer moves on — which is exactly what happened during the v1.0 capability catch-up, where the reference tree left MID `info/*`, BESS `info/{part,serial,firmware}` and PV `info/firmware-version` +unvalued long after panelbench published all of them. The drift was found by hand. + +The test did not fail, because it never ran: `PANELBENCH_DIR` named a directory that did not exist, so it skipped, and a skip renders in a summary line exactly like a pass. + +So: **if your run reports skips in `test_schema_one_conformance.py`, the provenance checks did not happen.** Run with `-rs` to see which and why: + +```bash +uv run pytest tests/ -q -rs +``` + +A correctly configured run has no skips in that file. The only skips you should expect are in `test_live_flat_differential.py`, which needs a live panel capture that is deliberately gitignored (see `scripts/capture_live_flat.py`); those are flat-firmware +differentials and are not part of schema_1 work. + +### When the peer check fails + +A failure means the vendored capture and panelbench have diverged. That is information, not an obstacle — decide which side is right: + +- **Panelbench moved and we should follow**: re-capture the fixture, and update `peer.commit` in `spec_lock.json` to the panelbench commit you captured from. Both, together — a capture without a commit bump records where the bytes came from as a guess. +- **We diverged deliberately** (the reference tree is trimmed and renamed to synthetic `example-*` identifiers, so it is not a verbatim copy): the comparison covers the artifacts that _are_ meant to match. Do not loosen it to accommodate a local edit. + +### Catalogs are pinned by commit, not version + +`spec_lock.json` records `synced_commit`, not a specification version. That is deliberate: the 2026-07-31 spec changelog changed circuit sign-frame semantics **in place** with no version bump and stated no re-pin was required. A version pin would not have +noticed. + ## Linting and Formatting Pre-commit hooks run automatically on commit. To run all hooks manually: From 3d196db0930ee6b0842ed4a31ce5e425d78a0d78 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:38:11 -0700 Subject: [PATCH 088/115] Re-vendor both captures from the producers that have moved past them Two vendored captures, both stale, both stale for the same reason: nothing compared them to their sources. `PANELBENCH_DIR` named a directory that did not exist, so the check that would have said so skipped, and a skip renders in a summary line exactly like a pass. **The v1.0 capture.** Re-taken from panelbench `0870dfd`, fifteen commits past the pin, and `peer.ref`/`peer.commit` move with it -- `feat/adopt-upstream-emitter` merged, so the ref is `main` now. **The flat capture.** `tests/fixtures/flat_wire.json` was taken at simulator v1.0.15 and described as frozen. Flat is a schema no longer being extended, not a producer no longer being fixed: 1.0.16 made an EVSE's node id its drive serial, which makes it a topic level and so forced it lower-case, since Homie 5 allows only `a`-`z`, `0`-`9` and `-` there. `SIM-EVSE-...` was legal as a property value and illegal as an id. panelbench followed on the v1.0 side the same week, and for nine days the two vendored captures named the same charger differently. The capture script now records the simulator commit its output came from, the way `spec_lock` records panelbench's. The lower-case serial is a correction to follow, not a regression to fight. Both producers now agree, and re-taking the flat capture removes the workaround this test was carrying: it compared flat's *serials* to v1.0's keys because flat's node ids were positional slots no panel publishes. Flat now names the node the way firmware does, so the comparison is direct and the fact that it is direct is asserted rather than assumed. **PV firmware-version.** panelbench values `info/firmware-version` on the PV now, which shrinks the declared-but-unpublished set to one -- PV `info/serial-number`, left unvalued on purpose, because valuing it moves the PV's device id and the upgrade rehearsal would stop comparing one PV and start comparing two. It also produced a new DER addition, and classifying it found a real gap: `schema_0` had no mapping row for `pv/software-version` at all, though the flat schema declares it on `energy.ebus.device.pv` and the captured `GET /api/v2/homie/schema` response proves it. Without the row the field would have been filed as introduced by v1.0, when in fact a flat panel whose inverter reports its firmware would publish it. The row and the read are added, and it is filed provisional -- `test_the_two_addition_buckets_are_told_apart_mechanically` is what refuses to let it be filed as new. 802 passed; pre-commit clean. --- .../src/span_panel_api_schema_0/consumer.py | 2 + .../span_panel_api_schema_0/field_metadata.py | 7 + .../spec/fixtures/simulator_tree.json | 8 +- .../spec/fixtures/simulator_wire.json | 255 +++++++++--------- .../span_panel_api_schema_1/spec_lock.json | 4 +- scripts/capture_flat_reference.py | 14 +- tests/fixtures/flat_wire.json | 156 +++++------ tests/test_schema_migration_delta.py | 73 +++-- tests/test_schema_one_against_simulator.py | 39 +-- 9 files changed, 309 insertions(+), 249 deletions(-) diff --git a/packages/schema-0/src/span_panel_api_schema_0/consumer.py b/packages/schema-0/src/span_panel_api_schema_0/consumer.py index f96485e..83f7a4e 100644 --- a/packages/schema-0/src/span_panel_api_schema_0/consumer.py +++ b/packages/schema-0/src/span_panel_api_schema_0/consumer.py @@ -342,6 +342,7 @@ def _build_pv(self) -> SpanPVSnapshot: vn = self._acc.get_prop(pv_node, "vendor-name") pn = self._acc.get_prop(pv_node, "product-name") + sw = self._acc.get_prop(pv_node, "software-version") nc = self._acc.get_prop(pv_node, "nameplate-capacity") feed = self._acc.get_prop(pv_node, "feed") rel_pos = self._acc.get_prop(pv_node, "relative-position") @@ -349,6 +350,7 @@ def _build_pv(self) -> SpanPVSnapshot: return SpanPVSnapshot( vendor_name=vn if vn else None, model=pn if pn else None, + software_version=sw if sw else None, nameplate_capacity_w=_parse_float(nc) if nc else None, feed_circuit_id=normalize_circuit_id(feed) if feed else None, relative_position=rel_pos.upper() if rel_pos else None, diff --git a/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py b/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py index c8aadc8..be7c0b7 100644 --- a/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py +++ b/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py @@ -96,6 +96,13 @@ # --- PV → pv.* ----------------------------------------------------------- (TYPE_PV, "vendor-name", "pv.vendor_name"), (TYPE_PV, "product-name", "pv.model"), + # Declared by the flat schema's `energy.ebus.device.pv` type and read here even + # though no capture we hold values it — neither the frozen simulator nor the live + # panel. The row is about what flat *can* say, not what one panel happened to send: + # `test_der_additions_are_provisional_or_attested_but_never_unexamined` classifies + # a v1.0-only field by whether flat has a property behind it, and without this row + # `pv.software_version` would be filed as introduced by v1.0 when flat declares it. + (TYPE_PV, "software-version", "pv.software_version"), (TYPE_PV, "nameplate-capacity", "pv.nameplate_capacity_w"), (TYPE_PV, "feed", "pv.feed_circuit_id"), (TYPE_PV, "relative-position", "pv.relative_position"), # IN_PANEL | UPSTREAM | DOWNSTREAM diff --git a/packages/schema-1/spec/fixtures/simulator_tree.json b/packages/schema-1/spec/fixtures/simulator_tree.json index 783181f..8546434 100644 --- a/packages/schema-1/spec/fixtures/simulator_tree.json +++ b/packages/schema-1/spec/fixtures/simulator_tree.json @@ -4172,8 +4172,8 @@ "1bfdc7ecebb0547bbe87a3696cddb0c0", "6fcb352679ad5bfb8c8a8eab06829b9f", "b9fa08f1eaaf5d129bd5c78e1d5d937f", - "sim-40t-001-SIM-EVSE-sim-40t-001", - "sim-40t-001-SIM-EVSE-sim-40t-001-2", + "sim-40t-001-sim-evse-sim-40t-001", + "sim-40t-001-sim-evse-sim-40t-001-2", "sim-40t-001-lugs-up", "sim-40t-001-lugs-dn", "sim-40t-001-pv-1" @@ -4586,7 +4586,7 @@ "type": "energy.ebus.device.mid", "version": 1786424627515 }, - "sim-40t-001-SIM-EVSE-sim-40t-001": { + "sim-40t-001-sim-evse-sim-40t-001": { "children": [], "extensions": [], "homie": "5.0", @@ -4674,7 +4674,7 @@ "type": "energy.ebus.device.evse", "version": 1786424627514 }, - "sim-40t-001-SIM-EVSE-sim-40t-001-2": { + "sim-40t-001-sim-evse-sim-40t-001-2": { "children": [], "extensions": [], "homie": "5.0", diff --git a/packages/schema-1/spec/fixtures/simulator_wire.json b/packages/schema-1/spec/fixtures/simulator_wire.json index 91c7664..7bd1bdb 100644 --- a/packages/schema-1/spec/fixtures/simulator_wire.json +++ b/packages/schema-1/spec/fixtures/simulator_wire.json @@ -1,14 +1,14 @@ { "13044bfbcbe5554b8f3dba126bce828f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627511, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Island)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617685, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Island)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Kitchen Outlets (Island)", "info/spaces": "10", "load-shed/priority": "NEVER", - "meter/active-power": "-281.6875885153822", - "meter/current": "2.3473965709615183", + "meter/active-power": "-295.52615631181357", + "meter/current": "2.462717969265113", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -18,11 +18,11 @@ "switch/relay-requester": "NONE" }, "1bfdc7ecebb0547bbe87a3696cddb0c0": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627514, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617688, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "50", - "connection/feeds-device-id": "sim-40t-001-SIM-EVSE-sim-40t-001-2", + "connection/feeds-device-id": "sim-40t-001-sim-evse-sim-40t-001-2", "connection/feeds-device-status": "OK", "connection/feeds-device-type": "energy.ebus.device.evse", "info/name": "SPAN Drive - Driveway", @@ -39,15 +39,15 @@ "switch/relay-requester": "NONE" }, "1eeeb748eeaa58edb7e9b7e9dbbdeca7": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627513, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Smoke Detectors\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617687, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Smoke Detectors\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Smoke Detectors", "info/spaces": "40", "load-shed/priority": "NEVER", - "meter/active-power": "-5.058420622591603", - "meter/current": "0.04215350518826336", + "meter/active-power": "-4.79282953433586", + "meter/current": "0.0399402461194655", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -57,15 +57,15 @@ "switch/relay-requester": "NONE" }, "2140a7e253ed54e3bc90a959081df615": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627511, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Refrigerator\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617686, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Refrigerator\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Refrigerator", "info/spaces": "15", "load-shed/priority": "NEVER", - "meter/active-power": "-116.70028748537023", - "meter/current": "0.9725023957114185", + "meter/active-power": "-124.32888886191921", + "meter/current": "1.0360740738493268", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "false", @@ -75,11 +75,11 @@ "switch/relay-requester": "NONE" }, "249a2f59782e5f1ab317c4632e79afad": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627514, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617688, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "50", - "connection/feeds-device-id": "sim-40t-001-SIM-EVSE-sim-40t-001", + "connection/feeds-device-id": "sim-40t-001-sim-evse-sim-40t-001", "connection/feeds-device-status": "OK", "connection/feeds-device-type": "energy.ebus.device.evse", "info/name": "SPAN Drive - Garage", @@ -96,15 +96,15 @@ "switch/relay-requester": "NONE" }, "3d9d86f303cc50d1827be57d4c667e53": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627509, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617684, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Bedroom Lights", "info/spaces": "4", "load-shed/priority": "NEVER", - "meter/active-power": "-61.55461466164948", - "meter/current": "0.5129551221804124", + "meter/active-power": "-32.54905694719264", + "meter/current": "0.27124214122660534", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -114,15 +114,15 @@ "switch/relay-requester": "NONE" }, "3eeb0eb1605e5a7eadac41994b7a096c": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627510, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617684, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Master Bedroom Outlets", "info/spaces": "7", "load-shed/priority": "NEVER", - "meter/active-power": "-145.97614877367525", - "meter/current": "1.2164679064472936", + "meter/active-power": "-156.07339750944152", + "meter/current": "1.3006116459120127", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -132,15 +132,15 @@ "switch/relay-requester": "NONE" }, "43a0521737db516f99f14a9964ea4af0": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627512, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Washing Machine\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617686, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Washing Machine\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Washing Machine", "info/spaces": "17", "load-shed/priority": "OFF_GRID", - "meter/active-power": "0.0", - "meter/current": "0.0", + "meter/active-power": "-738.3921387303916", + "meter/current": "6.153267822753263", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -150,7 +150,7 @@ "switch/relay-requester": "NONE" }, "4aeb08c46c2c5905a944166413f2f1ef": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627512, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garbage Disposal\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617686, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garbage Disposal\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", @@ -168,15 +168,15 @@ "switch/relay-requester": "NONE" }, "4ce8b30e8d3f5c49b9e0ab0c8caf4832": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627514, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Water Heater\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617688, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Water Heater\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", "info/name": "Water Heater", "info/spaces": "31,33", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-2572.8468862666637", - "meter/current": "10.720195359444432", + "meter/active-power": "-4500.0", + "meter/current": "18.75", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -186,7 +186,7 @@ "switch/relay-requester": "NONE" }, "4d1deb6acb065746b13207b1358f8ca7": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627512, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Dishwasher\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617686, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Dishwasher\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", @@ -204,15 +204,15 @@ "switch/relay-requester": "NONE" }, "516694a326a35cd88600b3520e8a981a": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627512, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Pool Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617687, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Pool Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Pool Pump", "info/spaces": "39", "load-shed/priority": "OFF_GRID", - "meter/active-power": "0.0", - "meter/current": "0.0", + "meter/active-power": "-217.26021119423217", + "meter/current": "1.8105017599519349", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -222,7 +222,7 @@ "switch/relay-requester": "NONE" }, "6fcb352679ad5bfb8c8a8eab06829b9f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627514, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617688, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", @@ -232,8 +232,8 @@ "info/name": "Solar Inverter", "info/spaces": "36,38", "load-shed/priority": "NEVER", - "meter/active-power": "0.0", - "meter/current": "0.0", + "meter/active-power": "2029.544536896322", + "meter/current": "8.456435570401341", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "false", @@ -243,15 +243,15 @@ "switch/relay-requester": "NONE" }, "770e2de52c33508a8a9ee8878064b46f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627509, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617683, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Master Bedroom Lights", "info/spaces": "1", "load-shed/priority": "NEVER", - "meter/active-power": "-27.27036078511234", - "meter/current": "0.22725300654260286", + "meter/active-power": "-15.079182336548172", + "meter/current": "0.1256598528045681", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -261,15 +261,15 @@ "switch/relay-requester": "NONE" }, "80a4fada833156ab8112f9d50e252b8f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627510, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Counter)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617684, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Counter)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Kitchen Outlets (Counter)", "info/spaces": "9", "load-shed/priority": "NEVER", - "meter/active-power": "-342.2266364449327", - "meter/current": "2.851888637041106", + "meter/active-power": "-313.35079337973053", + "meter/current": "2.6112566114977542", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -279,15 +279,15 @@ "switch/relay-requester": "NONE" }, "9429f828509e58d59cb5f0f9f5fee523": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627509, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617683, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Living Room Lights", "info/spaces": "2", "load-shed/priority": "NEVER", - "meter/active-power": "-31.967894805836238", - "meter/current": "0.26639912338196864", + "meter/active-power": "-20.77555195401851", + "meter/current": "0.17312959961682092", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -297,15 +297,15 @@ "switch/relay-requester": "NONE" }, "948dea7788aa5c959b99df0edfabead2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627513, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Heat Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617687, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Heat Pump\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", "info/name": "Heat Pump", "info/spaces": "27,29", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-1877.229732459936", - "meter/current": "7.8217905519164", + "meter/active-power": "-1263.514205767015", + "meter/current": "5.264642524029229", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -315,15 +315,15 @@ "switch/relay-requester": "NONE" }, "af731c49a6785a4cb2ea5549fb8bce7e": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627513, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Main HVAC\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617687, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Main HVAC\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", "info/name": "Main HVAC", "info/spaces": "23,25", "load-shed/priority": "NEVER", - "meter/active-power": "-570.9799967955347", - "meter/current": "2.3790833199813948", + "meter/active-power": "-618.5089366700175", + "meter/current": "2.5771205694584065", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -333,15 +333,15 @@ "switch/relay-requester": "NONE" }, "afe90839f2725e3e962fb05afa2b6d43": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627512, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Chest Freezer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617686, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Chest Freezer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Chest Freezer", "info/spaces": "19", "load-shed/priority": "NEVER", - "meter/active-power": "-79.15726635065371", - "meter/current": "0.6596438862554476", + "meter/active-power": "-86.21815758174944", + "meter/current": "0.7184846465145787", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "false", @@ -351,15 +351,15 @@ "switch/relay-requester": "NONE" }, "b24483358d29589d8e91d3bf11113269": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627511, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Office Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617685, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Office Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Office Outlets", "info/spaces": "11", "load-shed/priority": "NEVER", - "meter/active-power": "-322.53151973655616", - "meter/current": "2.6877626644713013", + "meter/active-power": "-259.11072766424627", + "meter/current": "2.159256063868719", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -369,15 +369,15 @@ "switch/relay-requester": "NONE" }, "b9fa08f1eaaf5d129bd5c78e1d5d937f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627514, \"type\": \"energy.ebus.device.circuit\", \"name\": \"kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617688, \"type\": \"energy.ebus.device.circuit\", \"name\": \"kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "kitchen Lights", "info/spaces": "3", "load-shed/priority": "NEVER", - "meter/active-power": "-143.4203096288541", - "meter/current": "1.1951692469071176", + "meter/active-power": "-141.27576756242206", + "meter/current": "1.177298063020184", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -387,15 +387,15 @@ "switch/relay-requester": "NONE" }, "be7742043a06554aab2a1e38cc776603": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627513, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Oven/Range\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617687, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Oven/Range\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "40", "info/name": "Electric Oven/Range", "info/spaces": "28,30", "load-shed/priority": "OFF_GRID", - "meter/active-power": "0.0", - "meter/current": "0.0", + "meter/active-power": "-5000.0", + "meter/current": "20.833333333333332", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -405,15 +405,15 @@ "switch/relay-requester": "NONE" }, "c058aa11287f50f9b81e5160a0678869": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627510, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bathroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617684, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bathroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Bathroom Lights", "info/spaces": "5", "load-shed/priority": "NEVER", - "meter/active-power": "-19.07638814259435", - "meter/current": "0.15896990118828624", + "meter/active-power": "-11.460658123756456", + "meter/current": "0.09550548436463714", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -423,15 +423,15 @@ "switch/relay-requester": "NONE" }, "c339ec7ce7ff521ca7646f9606baff9f": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627511, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Guest Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617685, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Guest Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Guest Room Outlets", "info/spaces": "14", "load-shed/priority": "NEVER", - "meter/active-power": "-139.79811201824884", - "meter/current": "1.1649842668187405", + "meter/active-power": "-137.99326566949193", + "meter/current": "1.1499438805790994", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -441,15 +441,15 @@ "switch/relay-requester": "NONE" }, "d1ff145887a05b839ede89409c27b398": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627511, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617685, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Garage Outlets", "info/spaces": "12", "load-shed/priority": "NEVER", - "meter/active-power": "-139.43521019790484", - "meter/current": "1.1619600849825402", + "meter/active-power": "-163.20433875732755", + "meter/current": "1.360036156311063", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -459,15 +459,15 @@ "switch/relay-requester": "NONE" }, "e0ac90e169e6550ea83fe0b1942f1d0e": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627510, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617684, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Living Room Outlets", "info/spaces": "8", "load-shed/priority": "NEVER", - "meter/active-power": "-233.85362788904922", - "meter/current": "1.9487802324087435", + "meter/active-power": "-244.30939312922507", + "meter/current": "2.035911609410209", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -477,15 +477,15 @@ "switch/relay-requester": "NONE" }, "e0bc156c85015a609d4132084dfcd6fe": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627512, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Microwave\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617686, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Microwave\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "20", "info/name": "Microwave", "info/spaces": "18", "load-shed/priority": "NEVER", - "meter/active-power": "0.0", - "meter/current": "0.0", + "meter/active-power": "-1500.0", + "meter/current": "12.5", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -495,15 +495,15 @@ "switch/relay-requester": "NONE" }, "edee3425d50d51ffb022ee999053b2b4": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627511, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Laundry Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617685, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Laundry Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Laundry Room Outlets", "info/spaces": "13", "load-shed/priority": "NEVER", - "meter/active-power": "-165.95107388066228", - "meter/current": "1.3829256156721856", + "meter/active-power": "-129.62101242313324", + "meter/current": "1.0801751035261102", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -513,15 +513,15 @@ "switch/relay-requester": "NONE" }, "ef972f063451539e8b2ad88e831d87b6": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627513, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Dryer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617687, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Dryer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "2", "breaker/rating": "30", "info/name": "Electric Dryer", "info/spaces": "20,22", "load-shed/priority": "OFF_GRID", - "meter/active-power": "0.0", - "meter/current": "0.0", + "meter/active-power": "-5000.0", + "meter/current": "20.833333333333332", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -531,15 +531,15 @@ "switch/relay-requester": "NONE" }, "f515a0f43b6555b1a196fbb62728c24e": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627510, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Exterior Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617684, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Exterior Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "breaker/poles": "1", "breaker/rating": "15", "info/name": "Exterior Lights", "info/spaces": "6", "load-shed/priority": "OFF_GRID", - "meter/active-power": "-62.821952544424036", - "meter/current": "0.5235162712035336", + "meter/active-power": "0.0", + "meter/current": "0.0", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0", "pcs/managed": "true", @@ -549,7 +549,7 @@ "switch/relay-requester": "NONE" }, "sim-40t-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627515, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Span Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"sim-40t-001-SIM-BESS-40T-001\", \"770e2de52c33508a8a9ee8878064b46f\", \"9429f828509e58d59cb5f0f9f5fee523\", \"3d9d86f303cc50d1827be57d4c667e53\", \"c058aa11287f50f9b81e5160a0678869\", \"f515a0f43b6555b1a196fbb62728c24e\", \"3eeb0eb1605e5a7eadac41994b7a096c\", \"e0ac90e169e6550ea83fe0b1942f1d0e\", \"80a4fada833156ab8112f9d50e252b8f\", \"13044bfbcbe5554b8f3dba126bce828f\", \"b24483358d29589d8e91d3bf11113269\", \"d1ff145887a05b839ede89409c27b398\", \"edee3425d50d51ffb022ee999053b2b4\", \"c339ec7ce7ff521ca7646f9606baff9f\", \"2140a7e253ed54e3bc90a959081df615\", \"4d1deb6acb065746b13207b1358f8ca7\", \"43a0521737db516f99f14a9964ea4af0\", \"e0bc156c85015a609d4132084dfcd6fe\", \"afe90839f2725e3e962fb05afa2b6d43\", \"4aeb08c46c2c5905a944166413f2f1ef\", \"516694a326a35cd88600b3520e8a981a\", \"1eeeb748eeaa58edb7e9b7e9dbbdeca7\", \"ef972f063451539e8b2ad88e831d87b6\", \"af731c49a6785a4cb2ea5549fb8bce7e\", \"948dea7788aa5c959b99df0edfabead2\", \"be7742043a06554aab2a1e38cc776603\", \"4ce8b30e8d3f5c49b9e0ab0c8caf4832\", \"249a2f59782e5f1ab317c4632e79afad\", \"1bfdc7ecebb0547bbe87a3696cddb0c0\", \"6fcb352679ad5bfb8c8a8eab06829b9f\", \"b9fa08f1eaaf5d129bd5c78e1d5d937f\", \"sim-40t-001-SIM-EVSE-sim-40t-001\", \"sim-40t-001-SIM-EVSE-sim-40t-001-2\", \"sim-40t-001-lugs-up\", \"sim-40t-001-lugs-dn\", \"sim-40t-001-pv-1\"], \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617689, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Span Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"sim-40t-001-SIM-BESS-40T-001\", \"770e2de52c33508a8a9ee8878064b46f\", \"9429f828509e58d59cb5f0f9f5fee523\", \"3d9d86f303cc50d1827be57d4c667e53\", \"c058aa11287f50f9b81e5160a0678869\", \"f515a0f43b6555b1a196fbb62728c24e\", \"3eeb0eb1605e5a7eadac41994b7a096c\", \"e0ac90e169e6550ea83fe0b1942f1d0e\", \"80a4fada833156ab8112f9d50e252b8f\", \"13044bfbcbe5554b8f3dba126bce828f\", \"b24483358d29589d8e91d3bf11113269\", \"d1ff145887a05b839ede89409c27b398\", \"edee3425d50d51ffb022ee999053b2b4\", \"c339ec7ce7ff521ca7646f9606baff9f\", \"2140a7e253ed54e3bc90a959081df615\", \"4d1deb6acb065746b13207b1358f8ca7\", \"43a0521737db516f99f14a9964ea4af0\", \"e0bc156c85015a609d4132084dfcd6fe\", \"afe90839f2725e3e962fb05afa2b6d43\", \"4aeb08c46c2c5905a944166413f2f1ef\", \"516694a326a35cd88600b3520e8a981a\", \"1eeeb748eeaa58edb7e9b7e9dbbdeca7\", \"ef972f063451539e8b2ad88e831d87b6\", \"af731c49a6785a4cb2ea5549fb8bce7e\", \"948dea7788aa5c959b99df0edfabead2\", \"be7742043a06554aab2a1e38cc776603\", \"4ce8b30e8d3f5c49b9e0ab0c8caf4832\", \"249a2f59782e5f1ab317c4632e79afad\", \"1bfdc7ecebb0547bbe87a3696cddb0c0\", \"6fcb352679ad5bfb8c8a8eab06829b9f\", \"b9fa08f1eaaf5d129bd5c78e1d5d937f\", \"sim-40t-001-sim-evse-sim-40t-001\", \"sim-40t-001-sim-evse-sim-40t-001-2\", \"sim-40t-001-lugs-up\", \"sim-40t-001-lugs-dn\", \"sim-40t-001-pv-1\"], \"extensions\": []}", "$state": "ready", "breaker/rating": "200", "door/state": "CLOSED", @@ -578,9 +578,9 @@ "pcs/requested-import-limit-active": "false", "pcs/requested-import-limit-enablement": "UNCONFIGURED", "power-flows/battery": "3500.0", - "power-flows/grid": "3839.544028005632", - "power-flows/pv": "0", - "power-flows/site": "7339.544028005632", + "power-flows/grid": "15443.800133211684", + "power-flows/pv": "2029.544536896322", + "power-flows/site": "20973.344670108007", "shed-forecast/confidence": "HIGH", "shed-forecast/full-charge-time-to-priority-shed": "3038", "shed-forecast/full-charge-total-time-remaining": "4320", @@ -593,10 +593,11 @@ "status/postal-code": "94103", "status/relay": "CLOSED", "status/time-zone": "America/Los_Angeles", - "status/wifi": "true" + "status/wifi": "true", + "status/wifi-ssid": "sim-wifi" }, "sim-40t-001-SIM-BESS-40T-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627515, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"sim-40t-001-SIM-BESS-40T-001-mid\"], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617689, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"sim-40t-001-SIM-BESS-40T-001-mid\"], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "info/firmware-version": "sim-bess/v0.1.0", "info/model": "SPAN Battery", @@ -610,44 +611,19 @@ "status/communication-state": "OK" }, "sim-40t-001-SIM-BESS-40T-001-mid": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627515, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001-SIM-BESS-40T-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617689, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001-SIM-BESS-40T-001\", \"extensions\": []}", "$state": "ready", "grid/grid-forming-entity": "GRID", "grid/grid-state": "UP", "grid/islanding-state": "ON_GRID", + "info/firmware-version": "sim-mid/v0.1.0", + "info/hardware-version": "rev1", + "info/model": "SPAN MID", "info/serial-number": "SIM-BESS-40T-001-mid", "info/vendor-name": "Span" }, - "sim-40t-001-SIM-EVSE-sim-40t-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627514, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", - "$state": "ready", - "config/max-charge-current": "32", - "config/user-max-charge-current": "32", - "info/firmware-version": "sim/v0.1.0", - "info/model": "SPAN Drive", - "info/part-number": "SPN-DRV-001", - "info/serial-number": "SIM-EVSE-sim-40t-001", - "info/vendor-name": "SPAN", - "meter/advertised-current": "32.0", - "status/status": "AVAILABLE", - "switch/lock-state": "UNLOCKED" - }, - "sim-40t-001-SIM-EVSE-sim-40t-001-2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627515, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", - "$state": "ready", - "config/max-charge-current": "32", - "config/user-max-charge-current": "32", - "info/firmware-version": "sim/v0.1.0", - "info/model": "SPAN Drive", - "info/part-number": "SPN-DRV-001", - "info/serial-number": "SIM-EVSE-sim-40t-001-2", - "info/vendor-name": "SPAN", - "meter/advertised-current": "32.0", - "status/status": "AVAILABLE", - "switch/lock-state": "UNLOCKED" - }, "sim-40t-001-lugs-dn": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627515, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617689, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "info/direction": "DOWNSTREAM", "meter/active-power": "0", @@ -657,23 +633,52 @@ "meter/imported-energy": "0" }, "sim-40t-001-lugs-up": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627515, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617689, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", "connection/fed-by-device-id": "sim-40t-001-SIM-BESS-40T-001", "connection/fed-by-device-status": "OK", "connection/fed-by-device-type": "energy.ebus.device.bess", "info/direction": "UPSTREAM", - "meter/active-power": "7339.544028005632", - "meter/current-a": "32.273652491578986", - "meter/current-b": "28.889214408467947", + "meter/active-power": "18943.800133211684", + "meter/current-a": "94.98295645861873", + "meter/current-b": "96.70778693308402", "meter/exported-energy": "0.0", "meter/imported-energy": "0.0" }, "sim-40t-001-pv-1": { - "$description": "{\"homie\": \"5.0\", \"version\": 1786424627515, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617689, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", "$state": "ready", + "info/firmware-version": "sim-pv/v0.1.0", "info/model": "IQ8PLUS-72-2-US", "info/nominal-power": "10000.0", "info/vendor-name": "Enphase" + }, + "sim-40t-001-sim-evse-sim-40t-001": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617689, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "sim/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "sim-evse-sim-40t-001", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "AVAILABLE", + "switch/lock-state": "UNLOCKED" + }, + "sim-40t-001-sim-evse-sim-40t-001-2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617689, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "sim/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "sim-evse-sim-40t-001-2", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "AVAILABLE", + "switch/lock-state": "UNLOCKED" } } diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json index ce5c6b1..b3f14bb 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -12,9 +12,9 @@ "framework": "0.7", "peer": { "repo": "https://github.com/SpanPanel/panelbench", - "ref": "feat/adopt-upstream-emitter", + "ref": "main", "role": "publisher", - "commit": "2d8234ffd42dcf78e5cbfafc6ee8a694db45f151", + "commit": "0870dfd21ac0557065c5219d27825e0222e2740a", "synced_commit": "4254526b0a8c11cab0d40fd700b1fc295c0479c6", "firmware_range": "r202633+", "fixtures": { diff --git a/scripts/capture_flat_reference.py b/scripts/capture_flat_reference.py index 02bc660..695fdd5 100644 --- a/scripts/capture_flat_reference.py +++ b/scripts/capture_flat_reference.py @@ -25,8 +25,18 @@ `$description` through the injected transport, and both land in the capture. The run asserts that rather than trusting it. -The flat simulator is frozen at `v1.0.15 — the locked flat schema release`, so the -output is stable and vendored rather than re-taken. Re-run only if that changes. +**Where the vendored bytes came from.** SpanPanel/simulator +`826be47d123137e63dfa232e411e868721f92f6d` (main, 2026-08-19, version 1.0.16). +Recorded as a commit rather than as "the frozen simulator", because that phrase is +what let this go stale: the capture was taken at v1.0.15 and read as permanent, and +1.0.16 then made an EVSE's node id its drive serial and forced that serial +lower-case — the flat half of a change panelbench made on the v1.0 side the same +week. For nine days the two vendored captures named the same charger differently, +and the test that compares them was the only thing that could say so. + +So: re-run this whenever the flat simulator publishes something new, and update the +commit above in the same change. A capture without a commit records where the bytes +came from as a guess. **Shape-stable, not byte-stable.** `noise_factor` and an advancing clock move 53 of the 559 topics on every run; the device set and the topic set do not move at diff --git a/tests/fixtures/flat_wire.json b/tests/fixtures/flat_wire.json index f711b35..37dfabd 100644 --- a/tests/fixtures/flat_wire.json +++ b/tests/fixtures/flat_wire.json @@ -1,11 +1,11 @@ { "sim-40t-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Span Panel\", \"id\": \"sim-40t-001\", \"nodes\": {\"13044bfbcbe5554b8f3dba126bce828f\": {\"type\": \"energy.ebus.device.circuit\"}, \"1bfdc7ecebb0547bbe87a3696cddb0c0\": {\"type\": \"energy.ebus.device.circuit\"}, \"1eeeb748eeaa58edb7e9b7e9dbbdeca7\": {\"type\": \"energy.ebus.device.circuit\"}, \"2140a7e253ed54e3bc90a959081df615\": {\"type\": \"energy.ebus.device.circuit\"}, \"249a2f59782e5f1ab317c4632e79afad\": {\"type\": \"energy.ebus.device.circuit\"}, \"3d9d86f303cc50d1827be57d4c667e53\": {\"type\": \"energy.ebus.device.circuit\"}, \"3eeb0eb1605e5a7eadac41994b7a096c\": {\"type\": \"energy.ebus.device.circuit\"}, \"43a0521737db516f99f14a9964ea4af0\": {\"type\": \"energy.ebus.device.circuit\"}, \"4aeb08c46c2c5905a944166413f2f1ef\": {\"type\": \"energy.ebus.device.circuit\"}, \"4ce8b30e8d3f5c49b9e0ab0c8caf4832\": {\"type\": \"energy.ebus.device.circuit\"}, \"4d1deb6acb065746b13207b1358f8ca7\": {\"type\": \"energy.ebus.device.circuit\"}, \"516694a326a35cd88600b3520e8a981a\": {\"type\": \"energy.ebus.device.circuit\"}, \"6fcb352679ad5bfb8c8a8eab06829b9f\": {\"type\": \"energy.ebus.device.circuit\"}, \"770e2de52c33508a8a9ee8878064b46f\": {\"type\": \"energy.ebus.device.circuit\"}, \"80a4fada833156ab8112f9d50e252b8f\": {\"type\": \"energy.ebus.device.circuit\"}, \"9429f828509e58d59cb5f0f9f5fee523\": {\"type\": \"energy.ebus.device.circuit\"}, \"948dea7788aa5c959b99df0edfabead2\": {\"type\": \"energy.ebus.device.circuit\"}, \"af731c49a6785a4cb2ea5549fb8bce7e\": {\"type\": \"energy.ebus.device.circuit\"}, \"afe90839f2725e3e962fb05afa2b6d43\": {\"type\": \"energy.ebus.device.circuit\"}, \"b24483358d29589d8e91d3bf11113269\": {\"type\": \"energy.ebus.device.circuit\"}, \"b9fa08f1eaaf5d129bd5c78e1d5d937f\": {\"type\": \"energy.ebus.device.circuit\"}, \"be7742043a06554aab2a1e38cc776603\": {\"type\": \"energy.ebus.device.circuit\"}, \"bess\": {\"type\": \"energy.ebus.device.bess\"}, \"c058aa11287f50f9b81e5160a0678869\": {\"type\": \"energy.ebus.device.circuit\"}, \"c339ec7ce7ff521ca7646f9606baff9f\": {\"type\": \"energy.ebus.device.circuit\"}, \"core\": {\"type\": \"energy.ebus.device.distribution-enclosure.core\"}, \"d1ff145887a05b839ede89409c27b398\": {\"type\": \"energy.ebus.device.circuit\"}, \"e0ac90e169e6550ea83fe0b1942f1d0e\": {\"type\": \"energy.ebus.device.circuit\"}, \"e0bc156c85015a609d4132084dfcd6fe\": {\"type\": \"energy.ebus.device.circuit\"}, \"edee3425d50d51ffb022ee999053b2b4\": {\"type\": \"energy.ebus.device.circuit\"}, \"ef972f063451539e8b2ad88e831d87b6\": {\"type\": \"energy.ebus.device.circuit\"}, \"evse\": {\"type\": \"energy.ebus.device.evse\"}, \"evse-2\": {\"type\": \"energy.ebus.device.evse\"}, \"f515a0f43b6555b1a196fbb62728c24e\": {\"type\": \"energy.ebus.device.circuit\"}, \"lugs-downstream\": {\"type\": \"energy.ebus.device.lugs\"}, \"lugs-upstream\": {\"type\": \"energy.ebus.device.lugs\"}, \"pcs\": {\"type\": \"energy.ebus.device.pcs\"}, \"power-flows\": {\"type\": \"energy.ebus.device.power-flows\"}, \"pv\": {\"type\": \"energy.ebus.device.pv\"}}}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787210474828, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Span Panel\", \"nodes\": {\"13044bfbcbe5554b8f3dba126bce828f\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"1bfdc7ecebb0547bbe87a3696cddb0c0\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"1eeeb748eeaa58edb7e9b7e9dbbdeca7\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"2140a7e253ed54e3bc90a959081df615\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"249a2f59782e5f1ab317c4632e79afad\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"3d9d86f303cc50d1827be57d4c667e53\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"3eeb0eb1605e5a7eadac41994b7a096c\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"43a0521737db516f99f14a9964ea4af0\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"4aeb08c46c2c5905a944166413f2f1ef\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"4ce8b30e8d3f5c49b9e0ab0c8caf4832\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"4d1deb6acb065746b13207b1358f8ca7\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"516694a326a35cd88600b3520e8a981a\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"6fcb352679ad5bfb8c8a8eab06829b9f\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"770e2de52c33508a8a9ee8878064b46f\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"80a4fada833156ab8112f9d50e252b8f\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"9429f828509e58d59cb5f0f9f5fee523\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"948dea7788aa5c959b99df0edfabead2\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"af731c49a6785a4cb2ea5549fb8bce7e\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"afe90839f2725e3e962fb05afa2b6d43\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"b24483358d29589d8e91d3bf11113269\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"b9fa08f1eaaf5d129bd5c78e1d5d937f\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"be7742043a06554aab2a1e38cc776603\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"bess\": {\"name\": \"bess\", \"type\": \"energy.ebus.device.bess\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"product-name\": {\"name\": \"Product name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"software-version\": {\"name\": \"Software version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}, \"relative-position\": {\"name\": \"Relative position of the commissioned backup system WRT the distribution enclosure\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM,IN_PANEL\"}, \"feed\": {\"name\": \"Circuit ID upon which the commissioned backup system is landed\", \"datatype\": \"enum\"}, \"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}, \"connected\": {\"name\": \"Connected to backup system?\", \"datatype\": \"boolean\"}, \"grid-state\": {\"name\": \"Grid connection state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,ON_GRID,OFF_GRID\"}}}, \"c058aa11287f50f9b81e5160a0678869\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"c339ec7ce7ff521ca7646f9606baff9f\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"core\": {\"name\": \"core\", \"type\": \"energy.ebus.device.distribution-enclosure.core\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"software-version\": {\"name\": \"Software version\", \"datatype\": \"string\"}, \"door\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"grid-islandable\": {\"name\": \"Capable of operating with power while disconnected from the grid\", \"datatype\": \"boolean\"}, \"dominant-power-source\": {\"name\": \"Current dominant power source, load-shedding trigger\", \"datatype\": \"enum\", \"format\": \"GRID,BATTERY,PV,GENERATOR,NONE,UNKNOWN\", \"settable\": true}, \"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"l1-voltage\": {\"name\": \"L1 voltage\", \"datatype\": \"float\"}, \"l2-voltage\": {\"name\": \"L2 voltage\", \"datatype\": \"float\"}, \"breaker-rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"vendor-cloud\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}, \"d1ff145887a05b839ede89409c27b398\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"e0ac90e169e6550ea83fe0b1942f1d0e\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"e0bc156c85015a609d4132084dfcd6fe\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"edee3425d50d51ffb022ee999053b2b4\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"ef972f063451539e8b2ad88e831d87b6\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"f515a0f43b6555b1a196fbb62728c24e\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}, \"pcs-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"lugs-downstream\": {\"name\": \"lugs\", \"type\": \"energy.ebus.device.lugs\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}, \"feed\": {\"name\": \"Device the lugs are connected to, if known\", \"datatype\": \"string\"}, \"l1-current\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"l2-current\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"lugs-upstream\": {\"name\": \"lugs\", \"type\": \"energy.ebus.device.lugs\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}, \"feed\": {\"name\": \"Device the lugs are connected to, if known\", \"datatype\": \"string\"}, \"l1-current\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"l2-current\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.device.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"grid-import-limit\": {\"name\": \"Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"grid-import-limit-enablement\": {\"name\": \"Enablement status of the grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"grid-import-limit-active\": {\"name\": \"Is grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.device.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"pv\": {\"name\": \"pv\", \"type\": \"energy.ebus.device.pv\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"product-name\": {\"name\": \"Product name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"software-version\": {\"name\": \"Software version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"W\"}, \"relative-position\": {\"name\": \"Relative position of the commissioned PV system WRT the distribution enclosure\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM,IN_PANEL\"}, \"feed\": {\"name\": \"Circuit ID upon which the commissioned PV system is landed\", \"datatype\": \"enum\"}}}, \"sim-evse-sim-40t-001\": {\"name\": \"evse\", \"type\": \"energy.ebus.device.evse\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"product-name\": {\"name\": \"Product name\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"software-version\": {\"name\": \"Software version\", \"datatype\": \"string\"}, \"feed\": {\"name\": \"Circuit ID upon which the commissioned EVSE is landed\", \"datatype\": \"enum\"}, \"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,LOCKED,UNLOCKED\"}, \"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,AVAILABLE,PREPARING,CHARGING,SUSPENDED_EV,SUSPENDED_EVSE,FINISHING,RESERVED,FAULTED,UNAVAILABLE\"}, \"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"sim-evse-sim-40t-001-2\": {\"name\": \"evse\", \"type\": \"energy.ebus.device.evse\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"product-name\": {\"name\": \"Product name\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"software-version\": {\"name\": \"Software version\", \"datatype\": \"string\"}, \"feed\": {\"name\": \"Circuit ID upon which the commissioned EVSE is landed\", \"datatype\": \"enum\"}, \"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,LOCKED,UNLOCKED\"}, \"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,AVAILABLE,PREPARING,CHARGING,SUSPENDED_EV,SUSPENDED_EVSE,FINISHING,RESERVED,FAULTED,UNAVAILABLE\"}, \"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}}, \"children\": [], \"extensions\": []}", "$state": "ready", - "13044bfbcbe5554b8f3dba126bce828f/active-power": "-306.9374003926985", + "13044bfbcbe5554b8f3dba126bce828f/active-power": "-257.42953159214676", "13044bfbcbe5554b8f3dba126bce828f/always-on": "false", "13044bfbcbe5554b8f3dba126bce828f/breaker-rating": "20", - "13044bfbcbe5554b8f3dba126bce828f/current": "2.5578116699391544", + "13044bfbcbe5554b8f3dba126bce828f/current": "2.145246096601223", "13044bfbcbe5554b8f3dba126bce828f/dipole": "false", "13044bfbcbe5554b8f3dba126bce828f/exported-energy": "0.0", "13044bfbcbe5554b8f3dba126bce828f/imported-energy": "0.0", @@ -34,10 +34,10 @@ "1bfdc7ecebb0547bbe87a3696cddb0c0/shed-priority": "OFF_GRID", "1bfdc7ecebb0547bbe87a3696cddb0c0/sheddable": "true", "1bfdc7ecebb0547bbe87a3696cddb0c0/space": "35", - "1eeeb748eeaa58edb7e9b7e9dbbdeca7/active-power": "-5.424609262306141", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/active-power": "-4.6189977226181265", "1eeeb748eeaa58edb7e9b7e9dbbdeca7/always-on": "false", "1eeeb748eeaa58edb7e9b7e9dbbdeca7/breaker-rating": "15", - "1eeeb748eeaa58edb7e9b7e9dbbdeca7/current": "0.045205077185884505", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/current": "0.038491647688484384", "1eeeb748eeaa58edb7e9b7e9dbbdeca7/dipole": "false", "1eeeb748eeaa58edb7e9b7e9dbbdeca7/exported-energy": "0.0", "1eeeb748eeaa58edb7e9b7e9dbbdeca7/imported-energy": "0.0", @@ -50,10 +50,10 @@ "1eeeb748eeaa58edb7e9b7e9dbbdeca7/shed-priority": "NEVER", "1eeeb748eeaa58edb7e9b7e9dbbdeca7/sheddable": "false", "1eeeb748eeaa58edb7e9b7e9dbbdeca7/space": "40", - "2140a7e253ed54e3bc90a959081df615/active-power": "-136.64199310087642", + "2140a7e253ed54e3bc90a959081df615/active-power": "-136.30423431833574", "2140a7e253ed54e3bc90a959081df615/always-on": "false", "2140a7e253ed54e3bc90a959081df615/breaker-rating": "20", - "2140a7e253ed54e3bc90a959081df615/current": "1.1386832758406369", + "2140a7e253ed54e3bc90a959081df615/current": "1.1358686193194645", "2140a7e253ed54e3bc90a959081df615/dipole": "false", "2140a7e253ed54e3bc90a959081df615/exported-energy": "0.0", "2140a7e253ed54e3bc90a959081df615/imported-energy": "0.0", @@ -82,10 +82,10 @@ "249a2f59782e5f1ab317c4632e79afad/shed-priority": "OFF_GRID", "249a2f59782e5f1ab317c4632e79afad/sheddable": "true", "249a2f59782e5f1ab317c4632e79afad/space": "32", - "3d9d86f303cc50d1827be57d4c667e53/active-power": "-8.491630065734094", + "3d9d86f303cc50d1827be57d4c667e53/active-power": "-8.477903365805087", "3d9d86f303cc50d1827be57d4c667e53/always-on": "false", "3d9d86f303cc50d1827be57d4c667e53/breaker-rating": "15", - "3d9d86f303cc50d1827be57d4c667e53/current": "0.07076358388111745", + "3d9d86f303cc50d1827be57d4c667e53/current": "0.0706491947150424", "3d9d86f303cc50d1827be57d4c667e53/dipole": "false", "3d9d86f303cc50d1827be57d4c667e53/exported-energy": "0.0", "3d9d86f303cc50d1827be57d4c667e53/imported-energy": "0.0", @@ -98,10 +98,10 @@ "3d9d86f303cc50d1827be57d4c667e53/shed-priority": "NEVER", "3d9d86f303cc50d1827be57d4c667e53/sheddable": "false", "3d9d86f303cc50d1827be57d4c667e53/space": "4", - "3eeb0eb1605e5a7eadac41994b7a096c/active-power": "-162.67620727626297", + "3eeb0eb1605e5a7eadac41994b7a096c/active-power": "-158.08771882018547", "3eeb0eb1605e5a7eadac41994b7a096c/always-on": "false", "3eeb0eb1605e5a7eadac41994b7a096c/breaker-rating": "15", - "3eeb0eb1605e5a7eadac41994b7a096c/current": "1.3556350606355247", + "3eeb0eb1605e5a7eadac41994b7a096c/current": "1.3173976568348789", "3eeb0eb1605e5a7eadac41994b7a096c/dipole": "false", "3eeb0eb1605e5a7eadac41994b7a096c/exported-energy": "0.0", "3eeb0eb1605e5a7eadac41994b7a096c/imported-energy": "0.0", @@ -146,10 +146,10 @@ "4aeb08c46c2c5905a944166413f2f1ef/shed-priority": "NEVER", "4aeb08c46c2c5905a944166413f2f1ef/sheddable": "false", "4aeb08c46c2c5905a944166413f2f1ef/space": "21", - "4ce8b30e8d3f5c49b9e0ab0c8caf4832/active-power": "-2584.688456943218", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/active-power": "-1245.8472739397114", "4ce8b30e8d3f5c49b9e0ab0c8caf4832/always-on": "false", "4ce8b30e8d3f5c49b9e0ab0c8caf4832/breaker-rating": "30", - "4ce8b30e8d3f5c49b9e0ab0c8caf4832/current": "10.769535237263408", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/current": "5.191030308082131", "4ce8b30e8d3f5c49b9e0ab0c8caf4832/dipole": "true", "4ce8b30e8d3f5c49b9e0ab0c8caf4832/exported-energy": "0.0", "4ce8b30e8d3f5c49b9e0ab0c8caf4832/imported-energy": "0.0", @@ -178,10 +178,10 @@ "4d1deb6acb065746b13207b1358f8ca7/shed-priority": "OFF_GRID", "4d1deb6acb065746b13207b1358f8ca7/sheddable": "true", "4d1deb6acb065746b13207b1358f8ca7/space": "16", - "516694a326a35cd88600b3520e8a981a/active-power": "-833.0787579160891", + "516694a326a35cd88600b3520e8a981a/active-power": "0.0", "516694a326a35cd88600b3520e8a981a/always-on": "false", "516694a326a35cd88600b3520e8a981a/breaker-rating": "20", - "516694a326a35cd88600b3520e8a981a/current": "6.942322982634076", + "516694a326a35cd88600b3520e8a981a/current": "0.0", "516694a326a35cd88600b3520e8a981a/dipole": "false", "516694a326a35cd88600b3520e8a981a/exported-energy": "0.0", "516694a326a35cd88600b3520e8a981a/imported-energy": "0.0", @@ -194,10 +194,10 @@ "516694a326a35cd88600b3520e8a981a/shed-priority": "OFF_GRID", "516694a326a35cd88600b3520e8a981a/sheddable": "true", "516694a326a35cd88600b3520e8a981a/space": "39", - "6fcb352679ad5bfb8c8a8eab06829b9f/active-power": "5814.805477599427", + "6fcb352679ad5bfb8c8a8eab06829b9f/active-power": "0.0", "6fcb352679ad5bfb8c8a8eab06829b9f/always-on": "false", "6fcb352679ad5bfb8c8a8eab06829b9f/breaker-rating": "30", - "6fcb352679ad5bfb8c8a8eab06829b9f/current": "24.22835615666428", + "6fcb352679ad5bfb8c8a8eab06829b9f/current": "0.0", "6fcb352679ad5bfb8c8a8eab06829b9f/dipole": "true", "6fcb352679ad5bfb8c8a8eab06829b9f/exported-energy": "0.0", "6fcb352679ad5bfb8c8a8eab06829b9f/imported-energy": "0.0", @@ -210,10 +210,10 @@ "6fcb352679ad5bfb8c8a8eab06829b9f/shed-priority": "NEVER", "6fcb352679ad5bfb8c8a8eab06829b9f/sheddable": "false", "6fcb352679ad5bfb8c8a8eab06829b9f/space": "36", - "770e2de52c33508a8a9ee8878064b46f/active-power": "-3.8689398042961014", + "770e2de52c33508a8a9ee8878064b46f/active-power": "-3.616782913825008", "770e2de52c33508a8a9ee8878064b46f/always-on": "false", "770e2de52c33508a8a9ee8878064b46f/breaker-rating": "15", - "770e2de52c33508a8a9ee8878064b46f/current": "0.032241165035800844", + "770e2de52c33508a8a9ee8878064b46f/current": "0.030139857615208397", "770e2de52c33508a8a9ee8878064b46f/dipole": "false", "770e2de52c33508a8a9ee8878064b46f/exported-energy": "0.0", "770e2de52c33508a8a9ee8878064b46f/imported-energy": "0.0", @@ -226,10 +226,10 @@ "770e2de52c33508a8a9ee8878064b46f/shed-priority": "NEVER", "770e2de52c33508a8a9ee8878064b46f/sheddable": "false", "770e2de52c33508a8a9ee8878064b46f/space": "1", - "80a4fada833156ab8112f9d50e252b8f/active-power": "-294.3991584148976", + "80a4fada833156ab8112f9d50e252b8f/active-power": "-294.05474521978084", "80a4fada833156ab8112f9d50e252b8f/always-on": "false", "80a4fada833156ab8112f9d50e252b8f/breaker-rating": "20", - "80a4fada833156ab8112f9d50e252b8f/current": "2.453326320124147", + "80a4fada833156ab8112f9d50e252b8f/current": "2.45045621016484", "80a4fada833156ab8112f9d50e252b8f/dipole": "false", "80a4fada833156ab8112f9d50e252b8f/exported-energy": "0.0", "80a4fada833156ab8112f9d50e252b8f/imported-energy": "0.0", @@ -242,10 +242,10 @@ "80a4fada833156ab8112f9d50e252b8f/shed-priority": "NEVER", "80a4fada833156ab8112f9d50e252b8f/sheddable": "false", "80a4fada833156ab8112f9d50e252b8f/space": "9", - "9429f828509e58d59cb5f0f9f5fee523/active-power": "-4.54538605930548", + "9429f828509e58d59cb5f0f9f5fee523/active-power": "-5.028052499839787", "9429f828509e58d59cb5f0f9f5fee523/always-on": "false", "9429f828509e58d59cb5f0f9f5fee523/breaker-rating": "15", - "9429f828509e58d59cb5f0f9f5fee523/current": "0.037878217160879", + "9429f828509e58d59cb5f0f9f5fee523/current": "0.04190043749866489", "9429f828509e58d59cb5f0f9f5fee523/dipole": "false", "9429f828509e58d59cb5f0f9f5fee523/exported-energy": "0.0", "9429f828509e58d59cb5f0f9f5fee523/imported-energy": "0.0", @@ -258,10 +258,10 @@ "9429f828509e58d59cb5f0f9f5fee523/shed-priority": "NEVER", "9429f828509e58d59cb5f0f9f5fee523/sheddable": "false", "9429f828509e58d59cb5f0f9f5fee523/space": "2", - "948dea7788aa5c959b99df0edfabead2/active-power": "-2104.816174600588", + "948dea7788aa5c959b99df0edfabead2/active-power": "-1419.9196540802902", "948dea7788aa5c959b99df0edfabead2/always-on": "false", "948dea7788aa5c959b99df0edfabead2/breaker-rating": "30", - "948dea7788aa5c959b99df0edfabead2/current": "8.770067394169116", + "948dea7788aa5c959b99df0edfabead2/current": "5.916331892001209", "948dea7788aa5c959b99df0edfabead2/dipole": "true", "948dea7788aa5c959b99df0edfabead2/exported-energy": "0.0", "948dea7788aa5c959b99df0edfabead2/imported-energy": "0.0", @@ -274,10 +274,10 @@ "948dea7788aa5c959b99df0edfabead2/shed-priority": "OFF_GRID", "948dea7788aa5c959b99df0edfabead2/sheddable": "true", "948dea7788aa5c959b99df0edfabead2/space": "27", - "af731c49a6785a4cb2ea5549fb8bce7e/active-power": "-644.9195219741802", + "af731c49a6785a4cb2ea5549fb8bce7e/active-power": "-186.46784746227968", "af731c49a6785a4cb2ea5549fb8bce7e/always-on": "false", "af731c49a6785a4cb2ea5549fb8bce7e/breaker-rating": "30", - "af731c49a6785a4cb2ea5549fb8bce7e/current": "2.687164674892417", + "af731c49a6785a4cb2ea5549fb8bce7e/current": "0.7769493644261654", "af731c49a6785a4cb2ea5549fb8bce7e/dipole": "true", "af731c49a6785a4cb2ea5549fb8bce7e/exported-energy": "0.0", "af731c49a6785a4cb2ea5549fb8bce7e/imported-energy": "0.0", @@ -290,10 +290,10 @@ "af731c49a6785a4cb2ea5549fb8bce7e/shed-priority": "NEVER", "af731c49a6785a4cb2ea5549fb8bce7e/sheddable": "false", "af731c49a6785a4cb2ea5549fb8bce7e/space": "23", - "afe90839f2725e3e962fb05afa2b6d43/active-power": "-69.85816854519369", + "afe90839f2725e3e962fb05afa2b6d43/active-power": "-76.06058554334422", "afe90839f2725e3e962fb05afa2b6d43/always-on": "false", "afe90839f2725e3e962fb05afa2b6d43/breaker-rating": "20", - "afe90839f2725e3e962fb05afa2b6d43/current": "0.5821514045432807", + "afe90839f2725e3e962fb05afa2b6d43/current": "0.6338382128612018", "afe90839f2725e3e962fb05afa2b6d43/dipole": "false", "afe90839f2725e3e962fb05afa2b6d43/exported-energy": "0.0", "afe90839f2725e3e962fb05afa2b6d43/imported-energy": "0.0", @@ -306,10 +306,10 @@ "afe90839f2725e3e962fb05afa2b6d43/shed-priority": "NEVER", "afe90839f2725e3e962fb05afa2b6d43/sheddable": "false", "afe90839f2725e3e962fb05afa2b6d43/space": "19", - "b24483358d29589d8e91d3bf11113269/active-power": "-267.7799960113091", + "b24483358d29589d8e91d3bf11113269/active-power": "-333.81473632426923", "b24483358d29589d8e91d3bf11113269/always-on": "false", "b24483358d29589d8e91d3bf11113269/breaker-rating": "15", - "b24483358d29589d8e91d3bf11113269/current": "2.231499966760909", + "b24483358d29589d8e91d3bf11113269/current": "2.7817894693689103", "b24483358d29589d8e91d3bf11113269/dipole": "false", "b24483358d29589d8e91d3bf11113269/exported-energy": "0.0", "b24483358d29589d8e91d3bf11113269/imported-energy": "0.0", @@ -322,10 +322,10 @@ "b24483358d29589d8e91d3bf11113269/shed-priority": "NEVER", "b24483358d29589d8e91d3bf11113269/sheddable": "false", "b24483358d29589d8e91d3bf11113269/space": "11", - "b9fa08f1eaaf5d129bd5c78e1d5d937f/active-power": "-128.18310004021967", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/active-power": "-146.66491277427593", "b9fa08f1eaaf5d129bd5c78e1d5d937f/always-on": "false", "b9fa08f1eaaf5d129bd5c78e1d5d937f/breaker-rating": "15", - "b9fa08f1eaaf5d129bd5c78e1d5d937f/current": "1.068192500335164", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/current": "1.2222076064522995", "b9fa08f1eaaf5d129bd5c78e1d5d937f/dipole": "false", "b9fa08f1eaaf5d129bd5c78e1d5d937f/exported-energy": "0.0", "b9fa08f1eaaf5d129bd5c78e1d5d937f/imported-energy": "0.0", @@ -338,10 +338,10 @@ "b9fa08f1eaaf5d129bd5c78e1d5d937f/shed-priority": "NEVER", "b9fa08f1eaaf5d129bd5c78e1d5d937f/sheddable": "false", "b9fa08f1eaaf5d129bd5c78e1d5d937f/space": "3", - "be7742043a06554aab2a1e38cc776603/active-power": "-4570.0202514924495", + "be7742043a06554aab2a1e38cc776603/active-power": "0.0", "be7742043a06554aab2a1e38cc776603/always-on": "false", "be7742043a06554aab2a1e38cc776603/breaker-rating": "40", - "be7742043a06554aab2a1e38cc776603/current": "19.041751047885207", + "be7742043a06554aab2a1e38cc776603/current": "0.0", "be7742043a06554aab2a1e38cc776603/dipole": "true", "be7742043a06554aab2a1e38cc776603/exported-energy": "0.0", "be7742043a06554aab2a1e38cc776603/imported-energy": "0.0", @@ -361,10 +361,10 @@ "bess/soc": "50.0", "bess/soe": "6.75", "bess/vendor-name": "Span", - "c058aa11287f50f9b81e5160a0678869/active-power": "-2.7604458634368765", + "c058aa11287f50f9b81e5160a0678869/active-power": "-2.685848418797581", "c058aa11287f50f9b81e5160a0678869/always-on": "false", "c058aa11287f50f9b81e5160a0678869/breaker-rating": "15", - "c058aa11287f50f9b81e5160a0678869/current": "0.023003715528640636", + "c058aa11287f50f9b81e5160a0678869/current": "0.02238207015664651", "c058aa11287f50f9b81e5160a0678869/dipole": "false", "c058aa11287f50f9b81e5160a0678869/exported-energy": "0.0", "c058aa11287f50f9b81e5160a0678869/imported-energy": "0.0", @@ -377,10 +377,10 @@ "c058aa11287f50f9b81e5160a0678869/shed-priority": "NEVER", "c058aa11287f50f9b81e5160a0678869/sheddable": "false", "c058aa11287f50f9b81e5160a0678869/space": "5", - "c339ec7ce7ff521ca7646f9606baff9f/active-power": "-170.30599501587994", + "c339ec7ce7ff521ca7646f9606baff9f/active-power": "-151.58523544953078", "c339ec7ce7ff521ca7646f9606baff9f/always-on": "false", "c339ec7ce7ff521ca7646f9606baff9f/breaker-rating": "15", - "c339ec7ce7ff521ca7646f9606baff9f/current": "1.4192166251323328", + "c339ec7ce7ff521ca7646f9606baff9f/current": "1.2632102954127566", "c339ec7ce7ff521ca7646f9606baff9f/dipole": "false", "c339ec7ce7ff521ca7646f9606baff9f/exported-energy": "0.0", "c339ec7ce7ff521ca7646f9606baff9f/imported-energy": "0.0", @@ -410,10 +410,10 @@ "core/vendor-cloud": "CONNECTED", "core/vendor-name": "Span", "core/wifi": "true", - "d1ff145887a05b839ede89409c27b398/active-power": "-136.31325580966814", + "d1ff145887a05b839ede89409c27b398/active-power": "-135.0052989391848", "d1ff145887a05b839ede89409c27b398/always-on": "false", "d1ff145887a05b839ede89409c27b398/breaker-rating": "15", - "d1ff145887a05b839ede89409c27b398/current": "1.1359437984139011", + "d1ff145887a05b839ede89409c27b398/current": "1.12504415782654", "d1ff145887a05b839ede89409c27b398/dipole": "false", "d1ff145887a05b839ede89409c27b398/exported-energy": "0.0", "d1ff145887a05b839ede89409c27b398/imported-energy": "0.0", @@ -426,10 +426,10 @@ "d1ff145887a05b839ede89409c27b398/shed-priority": "NEVER", "d1ff145887a05b839ede89409c27b398/sheddable": "false", "d1ff145887a05b839ede89409c27b398/space": "12", - "e0ac90e169e6550ea83fe0b1942f1d0e/active-power": "-219.7446532988136", + "e0ac90e169e6550ea83fe0b1942f1d0e/active-power": "-268.7730732161862", "e0ac90e169e6550ea83fe0b1942f1d0e/always-on": "false", "e0ac90e169e6550ea83fe0b1942f1d0e/breaker-rating": "15", - "e0ac90e169e6550ea83fe0b1942f1d0e/current": "1.83120544415678", + "e0ac90e169e6550ea83fe0b1942f1d0e/current": "2.239775610134885", "e0ac90e169e6550ea83fe0b1942f1d0e/dipole": "false", "e0ac90e169e6550ea83fe0b1942f1d0e/exported-energy": "0.0", "e0ac90e169e6550ea83fe0b1942f1d0e/imported-energy": "0.0", @@ -442,10 +442,10 @@ "e0ac90e169e6550ea83fe0b1942f1d0e/shed-priority": "NEVER", "e0ac90e169e6550ea83fe0b1942f1d0e/sheddable": "false", "e0ac90e169e6550ea83fe0b1942f1d0e/space": "8", - "e0bc156c85015a609d4132084dfcd6fe/active-power": "-1500.0", + "e0bc156c85015a609d4132084dfcd6fe/active-power": "0.0", "e0bc156c85015a609d4132084dfcd6fe/always-on": "false", "e0bc156c85015a609d4132084dfcd6fe/breaker-rating": "20", - "e0bc156c85015a609d4132084dfcd6fe/current": "12.5", + "e0bc156c85015a609d4132084dfcd6fe/current": "0.0", "e0bc156c85015a609d4132084dfcd6fe/dipole": "false", "e0bc156c85015a609d4132084dfcd6fe/exported-energy": "0.0", "e0bc156c85015a609d4132084dfcd6fe/imported-energy": "0.0", @@ -458,10 +458,10 @@ "e0bc156c85015a609d4132084dfcd6fe/shed-priority": "NEVER", "e0bc156c85015a609d4132084dfcd6fe/sheddable": "false", "e0bc156c85015a609d4132084dfcd6fe/space": "18", - "edee3425d50d51ffb022ee999053b2b4/active-power": "-155.86726575300366", + "edee3425d50d51ffb022ee999053b2b4/active-power": "-170.9170380907025", "edee3425d50d51ffb022ee999053b2b4/always-on": "false", "edee3425d50d51ffb022ee999053b2b4/breaker-rating": "15", - "edee3425d50d51ffb022ee999053b2b4/current": "1.2988938812750306", + "edee3425d50d51ffb022ee999053b2b4/current": "1.4243086507558542", "edee3425d50d51ffb022ee999053b2b4/dipole": "false", "edee3425d50d51ffb022ee999053b2b4/exported-energy": "0.0", "edee3425d50d51ffb022ee999053b2b4/imported-energy": "0.0", @@ -490,28 +490,10 @@ "ef972f063451539e8b2ad88e831d87b6/shed-priority": "OFF_GRID", "ef972f063451539e8b2ad88e831d87b6/sheddable": "true", "ef972f063451539e8b2ad88e831d87b6/space": "20", - "evse-2/advertised-current": "32.0", - "evse-2/feed": "1bfdc7ecebb0547bbe87a3696cddb0c0", - "evse-2/lock-state": "UNLOCKED", - "evse-2/part-number": "SPN-DRV-001", - "evse-2/product-name": "SPAN Drive", - "evse-2/serial-number": "SIM-EVSE-sim-40t-001-2", - "evse-2/software-version": "sim/v0.1.0", - "evse-2/status": "AVAILABLE", - "evse-2/vendor-name": "SPAN", - "evse/advertised-current": "32.0", - "evse/feed": "249a2f59782e5f1ab317c4632e79afad", - "evse/lock-state": "UNLOCKED", - "evse/part-number": "SPN-DRV-001", - "evse/product-name": "SPAN Drive", - "evse/serial-number": "SIM-EVSE-sim-40t-001", - "evse/software-version": "sim/v0.1.0", - "evse/status": "AVAILABLE", - "evse/vendor-name": "SPAN", - "f515a0f43b6555b1a196fbb62728c24e/active-power": "0.0", + "f515a0f43b6555b1a196fbb62728c24e/active-power": "-18.26908512247869", "f515a0f43b6555b1a196fbb62728c24e/always-on": "false", "f515a0f43b6555b1a196fbb62728c24e/breaker-rating": "15", - "f515a0f43b6555b1a196fbb62728c24e/current": "0.0", + "f515a0f43b6555b1a196fbb62728c24e/current": "0.15224237602065574", "f515a0f43b6555b1a196fbb62728c24e/dipole": "false", "f515a0f43b6555b1a196fbb62728c24e/exported-energy": "0.0", "f515a0f43b6555b1a196fbb62728c24e/imported-energy": "0.0", @@ -524,18 +506,18 @@ "f515a0f43b6555b1a196fbb62728c24e/shed-priority": "OFF_GRID", "f515a0f43b6555b1a196fbb62728c24e/sheddable": "true", "f515a0f43b6555b1a196fbb62728c24e/space": "6", - "lugs-downstream/active-power": "8496.515890041", + "lugs-downstream/active-power": "5023.628555813588", "lugs-downstream/direction": "DOWNSTREAM", "lugs-downstream/exported-energy": "0.0", "lugs-downstream/imported-energy": "0.0", - "lugs-downstream/l1-current": "82.62282478358763", - "lugs-downstream/l2-current": "85.09489892674448", - "lugs-upstream/active-power": "8496.515890040999", + "lugs-downstream/l1-current": "22.90269991803881", + "lugs-downstream/l2-current": "18.960871380407756", + "lugs-upstream/active-power": "5023.628555813588", "lugs-upstream/direction": "UPSTREAM", "lugs-upstream/exported-energy": "0.0", "lugs-upstream/imported-energy": "0.0", - "lugs-upstream/l1-current": "82.62282478358763", - "lugs-upstream/l2-current": "85.09489892674448", + "lugs-upstream/l1-current": "22.90269991803881", + "lugs-upstream/l2-current": "18.960871380407756", "pcs/active": "false", "pcs/enabled": "false", "pcs/feed-import-limit": "0.0", @@ -551,13 +533,31 @@ "pcs/requested-import-limit": "0.0", "pcs/requested-import-limit-active": "false", "pcs/requested-import-limit-enablement": "UNCONFIGURED", - "power-flows/battery": "3500.0", - "power-flows/grid": "4996.515890040999", - "power-flows/pv": "5814.805477599427", - "power-flows/site": "14311.321367640427", + "power-flows/battery": "-3500.0", + "power-flows/grid": "-1523.6285558135878", + "power-flows/pv": "0", + "power-flows/site": "5023.628555813588", "pv/feed": "6fcb352679ad5bfb8c8a8eab06829b9f", "pv/nameplate-capacity": "10000.0", "pv/relative-position": "IN_PANEL", - "pv/vendor-name": "Enphase" + "pv/vendor-name": "Enphase", + "sim-evse-sim-40t-001-2/advertised-current": "32.0", + "sim-evse-sim-40t-001-2/feed": "1bfdc7ecebb0547bbe87a3696cddb0c0", + "sim-evse-sim-40t-001-2/lock-state": "UNLOCKED", + "sim-evse-sim-40t-001-2/part-number": "SPN-DRV-001", + "sim-evse-sim-40t-001-2/product-name": "SPAN Drive", + "sim-evse-sim-40t-001-2/serial-number": "sim-evse-sim-40t-001-2", + "sim-evse-sim-40t-001-2/software-version": "sim/v0.1.0", + "sim-evse-sim-40t-001-2/status": "AVAILABLE", + "sim-evse-sim-40t-001-2/vendor-name": "SPAN", + "sim-evse-sim-40t-001/advertised-current": "32.0", + "sim-evse-sim-40t-001/feed": "249a2f59782e5f1ab317c4632e79afad", + "sim-evse-sim-40t-001/lock-state": "UNLOCKED", + "sim-evse-sim-40t-001/part-number": "SPN-DRV-001", + "sim-evse-sim-40t-001/product-name": "SPAN Drive", + "sim-evse-sim-40t-001/serial-number": "sim-evse-sim-40t-001", + "sim-evse-sim-40t-001/software-version": "sim/v0.1.0", + "sim-evse-sim-40t-001/status": "AVAILABLE", + "sim-evse-sim-40t-001/vendor-name": "SPAN" } } diff --git a/tests/test_schema_migration_delta.py b/tests/test_schema_migration_delta.py index eddfa9d..be0c037 100644 --- a/tests/test_schema_migration_delta.py +++ b/tests/test_schema_migration_delta.py @@ -32,9 +32,17 @@ **What this cannot tell you, which matters as much as what it can.** -The flat side is the frozen simulator, a proxy for flat firmware rather than -firmware itself. The gap is narrower than "DER is unverified", and worth stating -precisely, because the two halves have very different support. +The flat side is the flat simulator, a proxy for flat firmware rather than firmware +itself. The gap is narrower than "DER is unverified", and worth stating precisely, +because the two halves have very different support. + +"Frozen" is the word this file used until 2026-08-20, and it was wrong in a way that +cost something: flat is a schema no longer being extended, not a producer no longer +being fixed. 1.0.16 corrected an EVSE's node id to be its drive serial, the capture +was not re-taken because it was believed it never needed to be, and the two vendored +captures spent nine days naming the same charger differently. `tests/fixtures/ +flat_wire.json` now records the simulator commit it came from, the way the v1.0 +capture records panelbench's — see `scripts/capture_flat_reference.py`. *Telemetry is attested.* The simulator models the BESS and the Drives, and the integration renders their entities correctly against it — which is real evidence @@ -207,6 +215,7 @@ "battery.part_number", "battery.serial_number", "battery.software_version", + "pv.software_version", } ) """Additions that may not be additions, because the flat reference never sends them. @@ -215,14 +224,24 @@ BESS identity and no PV identity beyond `vendor-name`. This is narrower than "DER is unverified": the simulator models both devices and the integration renders their telemetry correctly against it, so `soc`, `soe`, `connected` and the rest -are attested. These four are the fields nothing sends and therefore nothing can +are attested. These five are the fields nothing sends and therefore nothing can vouch for. +`pv.software_version` joined on 2026-08-20, when panelbench started valuing the PV's +`info/firmware-version`. It belongs here rather than in `NEW_IN_V1_0` on a fact, not +a judgement: flat's `energy.ebus.device.pv` type declares `software-version` — the +captured `GET /api/v2/homie/schema` response says so, and `test_schema_provenance.py` +holds that against the panel — so a flat panel whose inverter reports its firmware +would publish it and this would be identity. Neither the frozen simulator nor the one +live panel available values it, so nothing here can vouch for it yet. `schema_0` had +no mapping row for the property at all until then, which is the gap +`test_the_two_addition_buckets_are_told_apart_mechanically` exists to expose. + Expect this set to shrink toward **identity**, not toward semantic change, and after the 2026-08-10 identity normalisation that is now true of every member. Both adapters speak v1.0's vocabulary — `schema_0` translates flat's `bess/model` to `part_number` and its `product-name` to `model` — so a flat capture carrying BESS identity would -move all three into the identity bucket at once. Before the normalisation the two +move all four battery rows into the identity bucket at once. Before the normalisation the two `product_name` entries looked like genuine additions, because the designation had no flat home under flat's own names; it does under these. @@ -319,10 +338,9 @@ def test_both_captures_describe_the_same_logical_panel(flat: Any, parent_child: migration delta and two simulators being configured differently.""" assert flat.serial_number == parent_child.serial_number == _SERIAL assert len(flat.circuits) == len(parent_child.circuits) - # Count, not keys. The EVSE keys legitimately differ across the migration — - # that is a delta, not a configuration difference, and asserting sameness here - # would put a real finding in the premise where it reads as a broken fixture. - # `test_evse_identity_does_not_survive_the_migration` holds it instead. + # Count, not keys. Whether the EVSE keys match across the migration is a finding, + # not a premise — putting it here would make a real regression read as a broken + # fixture. `test_evse_identity_survives_the_migration` holds it instead. assert len(flat.evse) == len(parent_child.evse) @@ -346,20 +364,35 @@ def test_evse_identity_survives_the_migration(flat: Any, parent_child: Any) -> N from what this library hands over -- the snapshot key and `node_id` -- so if those move between schemas, a user's charger orphans and a duplicate appears beside it. - **The comparison is against firmware, not against the flat simulator.** On a real - panel the EVSE node id *is* the Drive's serial: SpanPanel/span#214 has the topic - `ebus/5//`, diagnostics keyed + **The comparison is against firmware, not against a simulator convention.** On a + real panel the EVSE node id *is* the Drive's serial: SpanPanel/span#214 has the + topic `ebus/5//`, diagnostics keyed `"evse": {"": ...}`, and a maintainer confirming that node id is what - the `unique_id` is built from. The frozen flat simulator instead names its nodes - `evse` / `evse-2`, positional slots no panel publishes -- so `set(flat.evse)` is - the wrong thing to assert against, and asserting it is what previously produced an - elaborate reconstruction of a naming scheme that does not exist. - - So flat's *serials* stand in for flat's keys, which is what firmware would have - published. The simulator gap is recorded in the delta document. + the `unique_id` is built from. + + The flat simulator used to name those nodes `evse` / `evse-2`, positional slots no + panel publishes, so this test had to compare flat's *serials* against v1.0's keys + and take on faith that firmware would key on the same string. Flat 1.0.16 closed + that gap -- an EVSE's node id is now its drive serial there too -- so the first + assertion below states the fact rather than assuming it, and the second compares + the two key sets directly. Both sides now name the drive the way firmware does. + + The same change forced the serial lower-case, because a node id is a topic level + and Homie 5 allows only `a`-`z`, `0`-`9` and `-` there. `SIM-EVSE-...` was legal as + a property value and illegal as an id. Both producers followed; the capture on this + side was re-taken from flat 1.0.16 to match, which is what makes the comparison + below one between two current producers rather than between a current one and a + stale byte copy. """ flat_identity = {evse.serial_number for evse in flat.evse.values()} - assert flat_identity == {None} or None not in flat_identity, "a flat EVSE published no serial to key on" + assert None not in flat_identity, "a flat EVSE published no serial to key on" + + assert set(flat.evse) == flat_identity, ( + f"flat keys its EVSEs {sorted(flat.evse)} and serials them {sorted(flat_identity)}. " + "Since flat 1.0.16 the node id is the drive serial, as it is on firmware; if these " + "have come apart the flat capture predates that and the comparison below is " + "measuring a simulator convention rather than an identity." + ) assert set(parent_child.evse) == flat_identity, ( "v1.0 EVSE keys do not match the serials flat publishes. On real firmware the " diff --git a/tests/test_schema_one_against_simulator.py b/tests/test_schema_one_against_simulator.py index 000c429..1881a14 100644 --- a/tests/test_schema_one_against_simulator.py +++ b/tests/test_schema_one_against_simulator.py @@ -99,7 +99,7 @@ def test_no_der_declares_a_model_it_never_publishes(adapter: SchemaOneAdapter) - Scope is exactly `model`, because that is what `circuit_nodes_missing_names()` measures for a DER — `PROP_MODEL` declared with no value, alongside circuits missing `PROP_NAME`. The wider declared-but-unpublished question is - `test_the_ders_still_declare_two_identity_fields_they_never_publish` below, + `test_the_pv_still_declares_an_identity_field_it_never_publishes` below, which is not empty. The consumer symptom is specific: an entity is created from the declaration, @@ -129,22 +129,21 @@ def test_no_der_declares_a_model_it_never_publishes(adapter: SchemaOneAdapter) - """The proxied DER classes, which are what the over-declaration check covers.""" -def test_the_ders_still_declare_two_identity_fields_they_never_publish() -> None: +def test_the_pv_still_declares_an_identity_field_it_never_publishes() -> None: """The rest of §5.2, which adopting the upstream emitter did *not* close. `circuit_nodes_missing_names()` looks only at `info/model`, so it reports - clean while three declared properties still arrive with no value. Reading the + clean while declared properties still arrive with no value. Reading the capture directly is the only way to see the whole class, and leaving it unmeasured would let "the model gap closed" read as "the gap closed". - `battery.software_version` in the delta analysis's Class B depends on the BESS - firmware-version below, so that mapping stays untestable until this moves — - `battery.serial_number`, its Class B twin, is now unblocked because the BESS - does publish `info/serial-number`. + It has done that job twice now, and both times the expectation shrank rather + than grew: the BESS pair closed on 2026-08-10, and PV `info/firmware-version` + on 2026-08-20. One declaration is left. Pinned as an exact set so it fails in either direction: a new over-declaration - appears, or one of these is finally published and the expectation should - shrink. + appears, or the last one is finally published and the expectation should + shrink again. **Keyed by device type, not device id.** The ids are `-` and move with the panel serial and the DER's own serial, so keying on them @@ -176,16 +175,20 @@ def test_the_ders_still_declare_two_identity_fields_they_never_publish() -> None ) assert gaps == { - # The BESS pair closed on 2026-08-10: panelbench now supplies a placeholder - # `firmware_version`, so the declaration stops being empty and the mapping - # downstream stops being untestable. Synthetic, so it attests the mapping and - # not what real firmware sends. + # The BESS pair closed on 2026-08-10 and PV `info/firmware-version` on + # 2026-08-20, both because panelbench supplied a value where the declaration + # had been empty. Synthetic values, so they attest the mapping and not what + # real firmware sends. # - # PV keeps both deliberately. This check is doing real work while it is - # non-empty, and filling every gap with invented values would retire the signal - # without making anything more true -- the BESS one was filled because a - # mapping was blocked on it, and these block nothing. - "energy.ebus.device.pv": ["info/firmware-version", "info/serial-number"], + # PV `info/serial-number` is the one left, and it is unpublished on purpose + # rather than overlooked. Valuing it moves the PV's device id from + # `-pv-1` to `-`, because the producer's identifier + # derivation prefers a serial over an instance id -- and that id is what a + # consumer's device-registry entry is built from, so the upgrade rehearsal + # would stop comparing one PV and start comparing two. Closing it means + # settling the flat side's PV id first, which is a question about the upgrade + # path rather than about a config value. + "energy.ebus.device.pv": ["info/serial-number"], }, f"the declared-but-unpublished set moved: {gaps}" From a07f70a9a68b6edb126831e91db6124d1f960645 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:38:47 -0700 Subject: [PATCH 089/115] Make the provenance checks run, and make un-configuring them loud `test_the_vendored_captures_match_the_simulator` is what catches a vendored capture going stale while the producer moves on. It has never run. No workflow set `PANELBENCH_DIR`, the developer `.env` named a directory that did not exist, and a skip renders in a summary line exactly like a pass. The same was true of `EBUS_SPEC_DIR` and the byte comparison of the vendored catalogs. Three changes, because there are two different questions here and one hole between them. **ci.yml clones both peers at the commits `spec_lock.json` pins.** That asks a question with a deterministic answer -- do our vendored bytes match the commit we claim they came from? -- which is answerable on any commit and fair to block a merge on. It catches an accidental local edit to a vendored file. The commits are read out of the lock file at run time by `.github/actions/peer-checkouts` rather than written into the workflow, so the pin keeps exactly one home; a workflow that restated a commit would agree with the lock file right up until someone re-vendored and updated only one of them. Both repositories are public, so no token is involved. **The skip helper fails when `CI` is set.** Locally a skip is right -- not every developer keeps sibling checkouts. In CI it means the wiring came undone, and skipping on that is how a check gets switched off by an environment that stops supplying a path. The three states stay distinct: unset, a path that is gone, and a checkout reaped to an empty skeleton each call for a different fix and each keeps its own message. `CI` rather than a variable of our own, because a runner already sets it, so switching this off means opting out of being an environment. `test_an_unconfigured_peer_checkout_fails_in_ci_and_skips_locally` holds both halves, in both environments, across all three states. Asserting only the CI half would leave the local half free to become a failure, which is the change that makes someone delete the check rather than configure it. **peer-drift.yml asks the other question, on a schedule, never on a pull request.** Whether the producer has moved past the pin needs panelbench's current head, and that answer changes because someone else pushed. Failing an author's unrelated change for it would teach everyone to ignore it. So it clones `peer.ref`, reports "branch is at X, we pin Y, N commits behind" with the subjects in the job summary whichever way it goes, and then runs the *same* conformance file the blocking job runs -- reusing it rather than reimplementing a diff, so there is no second definition of "the captures match" to drift. A red run therefore means the producer changed something we vendor, not merely that it advanced. Also fixes a NameError in the catalog comparison's failure message, which would have raised instead of reporting the first time CI made that check run. 802 passed; pre-commit clean. --- .env.example | 15 ++- .github/actions/peer-checkouts/action.yml | 96 +++++++++++++++++ .github/workflows/ci.yml | 21 +++- .github/workflows/peer-drift.yml | 125 ++++++++++++++++++++++ .gitignore | 5 + DEVELOPMENT.md | 21 +++- tests/test_schema_one_conformance.py | 104 +++++++++++++++--- 7 files changed, 368 insertions(+), 19 deletions(-) create mode 100644 .github/actions/peer-checkouts/action.yml create mode 100644 .github/workflows/peer-drift.yml diff --git a/.env.example b/.env.example index 900782b..19c9207 100644 --- a/.env.example +++ b/.env.example @@ -6,11 +6,16 @@ # needed. A value already exported in your shell wins over anything here — the # file supplies defaults, it does not override an intentional choice. # -# Everything below is optional. Without it the suite runs in full and the checks -# that need a sibling checkout skip themselves rather than fail, which is what CI -# does. They are the *provenance* half of the schema_1 conformance suite: they -# verify that the vendored copies still match their sources. The conformance and -# coverage checks, which are the ones that catch real defects, run regardless. +# Everything below is optional *here*. Without it the suite runs in full and the +# checks that need a sibling checkout skip themselves rather than fail. They are the +# *provenance* half of the schema_1 conformance suite: they verify that the vendored +# copies still match their sources. The conformance and coverage checks, which are +# the ones that catch real defects, run regardless. +# +# CI is not optional: it clones both peers at the commits spec_lock.json pins, and +# those checks fail rather than skip when `CI` is set. A skip reads in a summary line +# exactly like a pass, and that is how a stale vendored capture went unnoticed for +# nine days. See DEVELOPMENT.md, "A skip here is not a pass". # A checkout of the eBus specification. # diff --git a/.github/actions/peer-checkouts/action.yml b/.github/actions/peer-checkouts/action.yml new file mode 100644 index 0000000..f6ca792 --- /dev/null +++ b/.github/actions/peer-checkouts/action.yml @@ -0,0 +1,96 @@ +name: Peer checkouts +description: > + Clone the two repositories the schema_1 provenance checks verify against — the eBus + specification and SpanPanel/panelbench — and export EBUS_SPEC_DIR / PANELBENCH_DIR + for the steps that follow. + + Every value comes out of packages/schema-1/src/span_panel_api_schema_1/spec_lock.json, + which is the single home of the pin. A workflow that restated a commit here would + give the pin a second home, and the two would agree right up until the day someone + re-vendored and updated only one. + +inputs: + panelbench-ref: + description: > + Which panelbench to clone, and it decides which question the job asks. + + "pin" clones the exact commit peer.commit records, so the byte comparison asks + "do our vendored captures match the commit we claim they came from?" — a + deterministic question with a deterministic answer, safe to block a merge on. + + "default" clones peer.ref, the branch the producer develops on, so the same + comparison asks "has the producer moved past the pin?". That answer changes + because someone else pushed, so it must never gate a pull request. + required: false + default: pin + +outputs: + panelbench-pin: + description: The commit spec_lock.json pins, whichever ref was cloned. + value: ${{ steps.pins.outputs.panelbench-commit }} + panelbench-repo: + description: The panelbench repository, as owner/name. + value: ${{ steps.pins.outputs.panelbench-repo }} + panelbench-checkout: + description: The ref actually cloned — the pinned commit, or the producer's branch. + value: ${{ steps.pins.outputs.panelbench-checkout }} + +runs: + using: composite + steps: + - name: Read the peer pins out of spec_lock.json + id: pins + shell: bash + env: + PANELBENCH_REF_MODE: ${{ inputs.panelbench-ref }} + run: | + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json + import os + + with open("packages/schema-1/src/span_panel_api_schema_1/spec_lock.json") as handle: + lock = json.load(handle) + peer = lock["peer"] + + def slug(url: str) -> str: + """owner/name, which is what actions/checkout wants.""" + return url.removeprefix("https://github.com/").removesuffix(".git") + + mode = os.environ["PANELBENCH_REF_MODE"] + if mode not in ("pin", "default"): + raise SystemExit(f"::error::panelbench-ref must be 'pin' or 'default', got {mode!r}") + + print(f"spec-repo={slug(lock['spec_repo'])}") + print(f"spec-commit={lock['synced_commit']}") + print(f"panelbench-repo={slug(peer['repo'])}") + print(f"panelbench-commit={peer['commit']}") + print(f"panelbench-checkout={peer['commit'] if mode == 'pin' else peer['ref']}") + PY + + # Both are public, so no token is involved. If either ever goes private this is + # the step that starts failing, and the fix is a PAT with read access in `token:` + # rather than anything about the pin. + - name: Check out the eBus specification at synced_commit + uses: actions/checkout@v7 + with: + repository: ${{ steps.pins.outputs.spec-repo }} + ref: ${{ steps.pins.outputs.spec-commit }} + path: peers/specification + + - name: Check out panelbench + uses: actions/checkout@v7 + with: + repository: ${{ steps.pins.outputs.panelbench-repo }} + ref: ${{ steps.pins.outputs.panelbench-checkout }} + # History only where it is read: the drift job counts commits between the pin + # and the branch head, which a shallow clone cannot do. + fetch-depth: ${{ inputs.panelbench-ref == 'default' && '0' || '1' }} + path: peers/panelbench + + - name: Point the provenance checks at them + shell: bash + run: | + { + echo "EBUS_SPEC_DIR=$GITHUB_WORKSPACE/peers/specification" + echo "PANELBENCH_DIR=$GITHUB_WORKSPACE/peers/panelbench" + } >> "$GITHUB_ENV" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f154c4a..67ce9cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,22 @@ jobs: with: python-version: ${{ matrix.python-version }} + # The schema_1 provenance checks compare vendored bytes against the two + # repositories they were copied from, and skip when neither is reachable. They + # skipped in every run this workflow has ever done, which reads in the summary + # line exactly like passing -- see DEVELOPMENT.md, "A skip here is not a pass". + # Cloning both at the commits spec_lock.json pins turns them into a question with + # a deterministic answer: do our vendored bytes match the commit we say they came + # from? Whether the *producer* has moved past that pin is a different question + # with a moving answer, and it lives in peer-drift.yml so it cannot fail a pull + # request for something the author did not do. + # + # CI is set by the runner, and tests/test_schema_one_conformance.py fails rather + # than skips when it is -- so removing this step breaks the build instead of + # quietly switching the checks back off. + - name: Check out the peers the provenance checks verify against + uses: ./.github/actions/peer-checkouts + - name: Install uv uses: astral-sh/setup-uv@v7 with: @@ -40,9 +56,12 @@ jobs: run: | uv run pre-commit run --all-files + # -rs so a skip that does survive is named in the log rather than counted. The + # only ones expected here are test_live_flat_differential.py, which needs a + # capture from a real panel that is deliberately gitignored. - name: Run tests with pytest run: | - uv run pytest tests/ -v \ + uv run pytest tests/ -v -rs \ --cov=src/span_panel_api \ --cov=packages/schema-0/src/span_panel_api_schema_0 \ --cov-report=xml --cov-report=term-missing diff --git a/.github/workflows/peer-drift.yml b/.github/workflows/peer-drift.yml new file mode 100644 index 0000000..903b4ff --- /dev/null +++ b/.github/workflows/peer-drift.yml @@ -0,0 +1,125 @@ +name: Peer drift + +# Deliberately never `pull_request`. This asks whether the *producer* has moved past +# the commit we pin, and the answer changes because someone else pushed to panelbench. +# Failing an author's unrelated change for that would teach everyone to ignore it, +# which is how a check stops being a check. +# +# ci.yml asks the other half of the question -- do our vendored bytes still match the +# commit we claim they came from -- against the pinned commit, where the answer is +# deterministic and blocking a merge on it is fair. +on: + schedule: + # Daily. The drift this exists to catch took nine days to be noticed by hand. + - cron: "17 6 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + panelbench: + name: Has panelbench moved past the pin? + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.14" + + - name: Check out panelbench's own branch, and the specification at its pin + id: peers + uses: ./.github/actions/peer-checkouts + with: + panelbench-ref: default + + # Ahead of the comparison, so the summary names the distance whichever way the job + # goes. "N commits behind" with the subjects is what makes the result actionable; + # a red check with no names is a chore. + - name: Report the distance from the pin + env: + PIN: ${{ steps.peers.outputs.panelbench-pin }} + REPO: ${{ steps.peers.outputs.panelbench-repo }} + BRANCH: ${{ steps.peers.outputs.panelbench-checkout }} + run: | + git() { command git -C "$GITHUB_WORKSPACE/peers/panelbench" "$@"; } + head="$(git rev-parse HEAD)" + + if ! git merge-base --is-ancestor "$PIN" HEAD 2>/dev/null; then + { + echo "## $REPO" + echo + echo "\`$PIN\` is not an ancestor of \`$BRANCH\` (\`${head:0:12}\`)." + echo + echo "The pin names a commit this branch does not contain — a branch that was" + echo "rebased, squash-merged or deleted. \`peer.ref\` in \`spec_lock.json\` needs" + echo "to name a ref the pinned commit is actually on." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + behind="$(git rev-list --count "$PIN"..HEAD)" + { + echo "## $REPO" + echo + echo "\`$BRANCH\` is at \`${head:0:12}\`, we pin \`${PIN:0:12}\` — **$behind commits behind**." + if [ "$behind" -gt 0 ]; then + echo + echo '```' + git log --oneline --no-decorate "$PIN"..HEAD + echo '```' + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --all-packages + + # The same checks ci.yml runs, pointed at panelbench's branch instead of the pin. + # Reusing them rather than reimplementing a diff here is the point: whatever the + # byte comparison means, it means the same thing in both jobs, and there is no + # second definition of "the captures match" to drift. + # + # So a red run means the producer changed something we vendor, not merely that it + # advanced -- a README commit leaves this green while still being reported above. + - name: Compare the vendored captures against panelbench's branch + run: | + uv run pytest tests/test_schema_one_conformance.py -v -rs + + - name: Say what a failure means + if: failure() + env: + PIN: ${{ steps.peers.outputs.panelbench-pin }} + run: | + { + echo + echo "### We have fallen behind the producer" + echo + echo "panelbench has changed something this repository keeps a copy of, and the copy" + echo "still reflects \`${PIN:0:12}\`. Read the failing assertion above for which:" + echo "a capture, or the specification commit the producer itself pins." + echo + echo "For a capture, re-vendor and re-pin together, in one change:" + echo + echo '```bash' + echo "cp \$PANELBENCH_DIR/tests/conformance/fixtures/golden_tree.json \\" + echo " packages/schema-1/spec/fixtures/simulator_tree.json" + echo "cp \$PANELBENCH_DIR/tests/conformance/fixtures/golden_wire.json \\" + echo " packages/schema-1/spec/fixtures/simulator_wire.json" + echo '```' + echo + echo "then set \`peer.commit\` in \`packages/schema-1/src/span_panel_api_schema_1/spec_lock.json\`" + echo "to the commit you copied from. A capture without a commit bump records where the" + echo "bytes came from as a guess." + echo + echo "For the specification commit, the two sides are reading different vocabularies" + echo "until \`synced_commit\` and the vendored catalogs move together." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index cf89f56..60bfa2b 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,8 @@ coverage_output.log # of which belongs in a repository. The differential that reads them commits its # *verdict* only, never the capture, and skips when the file is absent. tests/fixtures/live_*.json + +# Peer checkouts. CI clones the eBus specification and SpanPanel/panelbench here so +# the provenance checks have something to compare vendored bytes against; the same +# layout works locally if you would rather not point .env at siblings. +/peers/ diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7f0a83f..713ecee 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -32,7 +32,8 @@ python scripts/coverage.py --full ## Conformance against the specification and the producer Some tests verify this library against two things it does not contain: the eBus **specification** (the capability catalogs vendored under `packages/schema-1/spec/catalogs/`) and **panelbench**, the producer whose captures are vendored as reference -payloads. Both are reached through a local checkout named by an environment variable, and both **skip when the variable is unset or wrong**. +payloads. Both are reached through a local checkout named by an environment variable. Locally, both **skip when the variable is unset or wrong** — not every developer keeps sibling checkouts. **Under `CI` they fail instead**, because CI clones both, so an +absent path there means the wiring came undone rather than that the checkout is unavailable. Copy `.env.example` to `.env` and point them at real checkouts: @@ -58,6 +59,24 @@ uv run pytest tests/ -q -rs A correctly configured run has no skips in that file. The only skips you should expect are in `test_live_flat_differential.py`, which needs a live panel capture that is deliberately gitignored (see `scripts/capture_live_flat.py`); those are flat-firmware differentials and are not part of schema_1 work. +It cost us a second time on 2026-08-20, in the other vendored capture. `tests/fixtures/flat_wire.json` was taken from the flat simulator at v1.0.15 and described as frozen; 1.0.16 then made an EVSE's node id its drive serial and forced that serial +lower-case, which is the flat half of a change panelbench made on the v1.0 side the same week. Nothing compared the capture to its source, so for nine days the two vendored captures named the same charger differently. `scripts/capture_flat_reference.py` +now records the simulator commit its output came from, for the same reason `spec_lock.json` records the other two. + +### Two questions, two workflows + +The peer checks answer a question whose shape depends on which panelbench you point them at, and the two answers belong in different places. + +- **`.github/workflows/ci.yml`** clones both peers at the commits `spec_lock.json` pins, via the `.github/actions/peer-checkouts` composite action, and runs the whole suite against them. The question is _do our vendored bytes match the commit we claim they + came from?_ — deterministic, answerable on any commit, and fair to block a merge on. It catches an accidental local edit to a vendored file. +- **`.github/workflows/peer-drift.yml`** runs on a schedule, never on a pull request, and clones panelbench at `peer.ref` — the branch the producer develops on. The question is _has the producer moved past the pin?_ Its answer changes because someone else + pushed, so it must not fail an author's unrelated change. It reports the distance from the pin in the job summary either way, and goes red only when the comparison itself fails, so panelbench advancing with a change we do not vendor stays green. + +Both repositories are public, so neither checkout needs a token. If either ever goes private, the checkout step in the composite action is what starts failing, and the fix is a read-scoped PAT in its `token:` — the pin is not involved. + +The commits come out of `packages/schema-1/src/span_panel_api_schema_1/spec_lock.json` at run time rather than being written into the workflows, so the pin keeps exactly one home. A workflow that restated a commit would agree with the lock file right up +until the day someone re-vendored and updated only one of them. + ### When the peer check fails A failure means the vendored capture and panelbench have diverged. That is information, not an obstacle — decide which side is right: diff --git a/tests/test_schema_one_conformance.py b/tests/test_schema_one_conformance.py index 18b07b0..41e1c58 100644 --- a/tests/test_schema_one_conformance.py +++ b/tests/test_schema_one_conformance.py @@ -17,12 +17,14 @@ Three checks with different reach, deliberately: -- **Conformance** — this adapter against the vendored catalogs. Always runs, so - CI needs no network and no sibling checkout. +- **Conformance** — this adapter against the vendored catalogs. Always runs, from + a vendored copy, so it needs neither network nor a sibling checkout. - **Coverage** — this adapter against a captured tree from the SPAN simulator, the producer our development is done against. Always runs, from a vendored copy. -- **Provenance** — the vendored copies against their sources. Skipped unless - `EBUS_SPEC_DIR` / `PANELBENCH_DIR` point at checkouts. +- **Provenance** — the vendored copies against their sources, which need + `EBUS_SPEC_DIR` / `PANELBENCH_DIR` to name checkouts. Skipped without them on a + developer machine and **failed** without them under `CI`, where the workflow + clones both: see `_unconfigured`, and DEVELOPMENT.md's "A skip here is not a pass". Provenance proves we copied the right bytes; it cannot prove we understood them. The first two are where the understanding gets checked, which is why they are the @@ -37,6 +39,7 @@ import os from pathlib import Path import re +from typing import NoReturn import pytest @@ -91,13 +94,40 @@ def _peer_fixtures() -> dict[str, str]: return {str(kind): str(path) for kind, path in fixtures.items()} +def _unconfigured(reason: str) -> NoReturn: + """Not configured: skip on a developer machine, fail in CI. + + Locally, skipping is right — not every developer keeps sibling checkouts, and a + provenance check is not what they are running the suite for. + + In CI it is the opposite. The workflow clones both peers and exports both + variables, so an unset or wrong path there does not mean "unavailable", it means + the wiring that makes these checks run has come undone. Skipping on that reads in + the summary line exactly like passing, which is how these checks stayed silent for + the nine days it took the vendored capture to go stale. A check that can be + switched off by a missing environment variable is a check nobody can rely on. + + `CI` rather than a variable of our own, because it is what GitHub Actions and every + other runner already set — an environment that stops supplying a path has to opt + *out* of being an environment, which is not something a workflow edit does by + accident. + """ + if os.environ.get("CI"): + pytest.fail( + f"{reason}. CI configures both peer checkouts, so this is the provenance " + "wiring being broken rather than a check that is unavailable — and a skip " + "here is indistinguishable from a pass." + ) + pytest.skip(reason) + + def _checkout(variable: str, what: str, expect: str | None = None) -> Path: - """A sibling checkout named by an environment variable, or skip. + """A sibling checkout named by an environment variable, or unconfigured. A variable that is unset and one pointing at a directory that is gone are the - same situation — the checkout is not available — and both should skip. Letting - a stale path through instead produces a FileNotFoundError from somewhere deep - in a comparison, which reads as a broken test rather than an unconfigured one. + same situation — the checkout is not available — and both take the same exit. + Letting a stale path through instead produces a FileNotFoundError from somewhere + deep in a comparison, which reads as a broken test rather than an unconfigured one. Set them in `.env`; see `.env.example`. "Gone" includes *emptied*, which is the form this actually takes. A checkout under @@ -106,15 +136,19 @@ def _checkout(variable: str, what: str, expect: str | None = None) -> Path: Presence of a directory proves nothing here; the caller names one that must hold at least one `.json`, which is what distinguishes a populated checkout from the skeleton of a reaped one. + + Each of the three states keeps its own message, because they call for different + actions — set the variable, fix the path, or re-clone — and collapsing them would + make the most confusing one, the reaped skeleton, look like the simplest one. """ configured = os.environ.get(variable) if not configured: - pytest.skip(f"set {variable} to {what}") + _unconfigured(f"set {variable} to {what}") path = Path(configured) if not path.is_dir(): - pytest.skip(f"{variable}={configured} does not exist; point it at {what}") + _unconfigured(f"{variable}={configured} does not exist; point it at {what}") if expect is not None and not any((path / expect).glob("*.json")): - pytest.skip(f"{variable}={configured} has no files under {expect}/ — the checkout is empty or is not {what}") + _unconfigured(f"{variable}={configured} has no files under {expect}/ — the checkout is empty or is not {what}") return path @@ -556,7 +590,7 @@ def test_vendored_catalogs_are_byte_identical_to_the_specification() -> None: ] assert not differing, ( - f"vendored catalogs differ from {spec_dir} (lockfile pins {_lock()['synced_commit']}): {differing}. " + f"vendored catalogs differ from {spec} (lockfile pins {_lock()['synced_commit']}): {differing}. " "Check the checkout is at synced_commit before assuming the copies are wrong." ) @@ -605,3 +639,49 @@ def test_the_peer_record_matches_the_simulator_lockfile() -> None: f"the simulator now pins {theirs['synced_commit']}, we recorded {_peer_str('synced_commit')}. " "Re-vendor and update both, or the two sides are reading different vocabularies." ) + + +def test_an_unconfigured_peer_checkout_fails_in_ci_and_skips_locally(monkeypatch: pytest.MonkeyPatch) -> None: + """The guard on the guard. + + Everything above this line is worth exactly as much as the thing that decides + whether it runs, and that thing is one `if`. It has already gone wrong once in the + other direction: `PANELBENCH_DIR` named a directory that did not exist, every peer + check skipped, and nine days of drift accumulated behind a summary line that read + like a pass. + + So the skip and the failure are both asserted, in both environments, for all three + of the states `_checkout` distinguishes. Asserting only the CI half would leave the + local half free to become a failure, which is the change that makes a developer + delete the check rather than configure it. + + `_checkout` is exercised through its public behaviour — the exception it raises — + rather than by inspecting `_unconfigured`, so this keeps holding if the branch + moves into the callers. + """ + outcomes = (pytest.fail.Exception, pytest.skip.Exception) + missing = "/nonexistent/peer/checkout" + + monkeypatch.delenv("CI", raising=False) + monkeypatch.setenv("PANELBENCH_DIR", missing) + with pytest.raises(outcomes, match="does not exist") as local: + _checkout("PANELBENCH_DIR", "a panelbench checkout") + assert local.type is pytest.skip.Exception, ( + f"off CI an unavailable checkout must skip, got {local.typename}. Failing instead is " + "what makes a developer without sibling checkouts delete the check rather than configure it" + ) + + monkeypatch.setenv("CI", "true") + for variable, value, expect, why in ( + ("PANELBENCH_DIR", "", None, "unset"), + ("PANELBENCH_DIR", missing, None, "a path that is gone"), + ("EBUS_SPEC_DIR", str(_SPEC), "no-such-directory", "a checkout reaped to an empty skeleton"), + ): + monkeypatch.setenv(variable, value) + with pytest.raises(outcomes) as raised: + _checkout(variable, "a peer checkout", expect=expect) + assert raised.type is pytest.fail.Exception, ( + f"under CI, {why} must fail rather than {raised.typename.lower()}: a peer check that " + "skips is one an environment can switch off, and the summary line cannot tell the " + "difference between that and a pass" + ) From e664c5c805129654b5bc027c6340c69dc3ed74cb Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:32:14 -0700 Subject: [PATCH 090/115] feat(schema-1): compare declared units and datatypes against the catalogs Sixteen capability catalogs have been vendored since v1.0 landed and were read only to assert that a catalog *exists* for every node the adapter addresses. Nothing opened the definition. So the comparison that catches a mislabel -- does the `unit` and `datatype` a producer declares for a property agree with the catalog's? -- was never made, and the one mislabel this repository has met was found by a person noticing that a sibling device declared the same quantity differently: `meter/active-power` in `kW` while the values are watts, a 1000x error that shipped. `span_panel_api_schema_1.catalog` makes that mechanical. It compares one declaration against one catalog definition and classifies the result; `tests/test_catalog_divergence.py` runs it across the four vendored producer captures and holds the outcome against an acknowledged-divergence register. Catalog definitions are passed in rather than read by path: the vendored spec is outside this distribution's wheel, and taking them as an argument is what would let a live-panel diagnostic reuse the same rules. Agreement is silence. Disagreement is surfaced and never silently resolved in either direction -- a finding is not a licence to change a wire reader to match the catalog, nor to assume the catalog is right. It is recorded with what the wire says, what the catalog says, which producers show it, a reason and a date, and it fails in both directions like every other baseline here: a new divergence fails until somebody records it, and a recorded divergence that has *disappeared* fails until its line is removed. The second direction is what keeps the register self-cleaning rather than a suppression list. Two rules keep it from producing false findings, and both were measured rather than assumed: - An abstract unit is a dimension. `soc/soe` and `info/nameplate-capacity` are `unit: "energy"`, which the specification requires a publisher to substitute a real unit for -- a BESS in kWh, a water heater in Wh. A member of the enumerated family is silent; echoing the placeholder back is not. A catalog unit token that is neither a known family nor a known concrete unit fails until a human classifies it, so a new abstract family upstream cannot arrive as sixty false findings. - An absence is terminal. A property no catalog defines -- the EVSE's `config` node, which is not an eBus capability at all -- has no definition to disagree with, so it is reported once as absent and never as a field mismatching against nothing. `_SPAN_EXTENSIONS` stays the single home for the read-set half of that question. The flat schema document is surveyed too, because it is where the known mislabel actually lives. It has no capability nodes, so its properties reach the catalogued vocabulary through the snapshot field path both adapters' metadata tables already name -- derived from those tables so the join cannot outlive them -- and only where the two spell the property identically. Fifteen flat properties reach a catalogued property under a different name (`dipole` for `breaker/poles`), and comparing across a rename would invent divergences out of the pre-catalog spelling that having two adapters already handles. Measured over the four captures: 2 divergences across 61 compared properties, 3.3%, in two shapes -- one unit, one datatype. Both are seeded in the register. --- CHANGELOG.md | 16 + DEVELOPMENT.md | 19 + .../src/span_panel_api_schema_1/catalog.py | 211 ++++++ tests/test_catalog_divergence.py | 685 ++++++++++++++++++ 4 files changed, 931 insertions(+) create mode 100644 packages/schema-1/src/span_panel_api_schema_1/catalog.py create mode 100644 tests/test_catalog_divergence.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d82cdae..87d6548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- **The capability catalogs are used as a validator, not just as a vocabulary list: `span_panel_api_schema_1.catalog`.** Sixteen catalogs have been vendored since v1.0 landed and were read only to assert that a catalog _exists_ for every node the adapter + addresses. Nothing compared a declared `unit` or `datatype` against the catalog's definition of the same property, which is the comparison that catches a mislabel — and the one mislabel this repository has met (`meter/active-power` declared `kW` while + the values are watts, a 1000x error) was found because a person noticed a sibling device declaring the same quantity differently. The new module compares one declaration against one catalog definition and classifies the result; + `tests/test_catalog_divergence.py` runs it across all four vendored producer captures and holds the outcome against an acknowledged-divergence register. +- **Agreement is silence; disagreement is surfaced, never silently resolved.** A finding is not a licence to change a wire reader to match the catalog, nor to assume the catalog is right — both sides have been wrong. It is recorded in `_REGISTER` with what + the wire says, what the catalog says, which producers show it, a reason and a date, and the baseline fails in both directions: a new divergence fails until somebody records it, and a recorded divergence that has **disappeared** fails until its line is + removed. That second direction is what keeps the register self-cleaning rather than a suppression list. +- **An abstract unit is a dimension, and comparing it as a string would report conformance as the defect.** `soc/soe`, `soc/total-energy-storage`, `soc/loadup-headroom` and `info/nameplate-capacity` are all `unit: "energy"`, which the specification + requires a publisher to substitute a real unit for — a BESS in kWh, a water heater in Wh. `UNIT_FAMILIES` enumerates membership rather than deriving it from an SI-prefix rule, so a member is silent, echoing the placeholder back is a finding, and an + energy unit nobody enumerated is a question for a human. A catalog unit token that is neither a known family nor a known concrete unit fails until it is classified, so a new abstract family upstream cannot arrive as sixty false findings. +- **An absence is terminal and is reported once.** A property no catalog defines — the EVSE's `config` node, which is not an eBus capability at all, and the `status`/`meter`/`info` extensions SPAN publishes — has no definition to disagree with, so it is + reported as absent rather than as every field mismatching against nothing. That keeps `_SPAN_EXTENSIONS` the single home for the read-set half of that question instead of duplicating its judgements here. +- **The flat schema document is surveyed too, and it is where the known mislabel lives.** It declares properties per device type with no capability node to look a catalog up by, so its properties reach the catalogued vocabulary through the snapshot field + path both adapters' metadata tables already name — derived from those tables rather than restated, so the join cannot outlive them. The join is admitted only where the two sides spell the property identically: fifteen flat properties reach a catalogued + property under a different name (`dipole` for `breaker/poles`, `software-version` for `info/firmware-version`), and comparing across a rename would invent divergences out of the pre-catalog spelling that having two adapters already handles. + - **Per-DER connection health reaches the snapshot: `SpanEvseSnapshot.connected` and `SpanPVSnapshot.connected`.** `battery.connected` has carried the enclosure's view of the link to the BESS since v1.0 landed, from the upstream lugs' `connection/fed-by-device-status`. The other half of the same capability — a circuit's `connection/feeds-device-status`, which is how the enclosure reports the link to a PV or a charger — reached nothing, so only one of a panel's three DER classes had a link-health field. Both new fields are `bool | None` and mirror `battery.connected` exactly, read by `build_pv` and `build_evse` through the new `feed_connection_statuses`. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 713ecee..c87dae8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -89,6 +89,25 @@ A failure means the vendored capture and panelbench have diverged. That is infor `spec_lock.json` records `synced_commit`, not a specification version. That is deliberate: the 2026-07-31 spec changelog changed circuit sign-frame semantics **in place** with no version bump and stated no re-pin was required. A version pin would not have noticed. +### The acknowledged-divergence register + +`test_schema_one_conformance.py` asks whether the names this adapter reads exist in the catalogs. `test_catalog_divergence.py` asks the next question, and it is the one that corrupts readings when the answer is wrong: **does the `unit` and `datatype` a +producer declares for a property agree with the catalog's definition of it?** Agreement is silence. Disagreement is a finding, and it is never resolved silently in either direction — do not change a wire reader to agree with the catalog, and do not assume +the catalog is right. Both have been wrong. + +Four producers are surveyed: the two vendored simulator captures, the reference parent/child tree, and the flat schema document captured from a live panel. The flat document has no capability nodes, so its properties reach the catalogued vocabulary through +the snapshot field path both adapters' metadata tables name — and only where the two spell the property identically, so a pre-catalog **rename** (`dipole` for `breaker/poles`) is left out rather than reported as a divergence. + +When a finding is real, record it in `_REGISTER` with what the wire says, what the catalog says, which producers show it, a reason, and a date. That is a human saying "SPAN ships this and we compensate", and it fails in both directions like every other +baseline here: a new divergence fails until somebody records it, and a **recorded divergence that has disappeared fails until its line is removed**. The second direction is what makes the register self-cleaning when a firmware or a catalog is fixed, and it +is why the register is not a suppression list. + +Two rules keep it from producing false findings: + +- **An abstract unit is a dimension, not a unit.** The catalog gives `soc/soe` and `info/nameplate-capacity` as `unit: "energy"` and requires the publisher to substitute a real one — a BESS in kWh, a water heater in Wh. A member of the family is silent; + echoing the token back is not. Membership is enumerated in `catalog.py`'s `UNIT_FAMILIES`, and a catalog unit token that is neither a known family nor a known concrete unit fails until a human classifies it. +- **An absence is terminal.** A property no catalog defines — the EVSE's `config` node, which is not an eBus capability at all — is reported once as absent and never as a unit or datatype mismatch against a definition that does not exist. + ## Linting and Formatting Pre-commit hooks run automatically on commit. To run all hooks manually: diff --git a/packages/schema-1/src/span_panel_api_schema_1/catalog.py b/packages/schema-1/src/span_panel_api_schema_1/catalog.py new file mode 100644 index 0000000..f358940 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/catalog.py @@ -0,0 +1,211 @@ +"""Compare what a producer declares against what a capability catalog defines. + +The registry used as a validator. Every property a device's ``$description`` +declares carries a ``unit`` and a ``datatype``; the eBus capability catalog for +that node declares the same two fields for the same property. Agreement is +silence. Disagreement is a finding, surfaced for a human — never resolved +silently in either direction, because either side can be the wrong one. The +last mislabel this catches by machine (`meter/active-power` in ``kW``, values in +watts) was found because a person noticed a sibling device declaring the same +quantity differently. + +**This module compares; it never sources.** Nothing here may become the place a +unit is read from. `field_metadata` takes units from each device's own +declaration precisely because the catalog is the superset across all hardware +and carries abstract units, and `test_an_abstract_unit_is_never_taken_from_the_catalog` +holds that line. The rules below exist to *judge* a declaration, which is a +different job from supplying one. + +**Catalog definitions are passed in, never read from disk.** The vendored +catalogs live under ``packages/schema-1/spec/``, outside this distribution's +wheel, so a module that read them by path would work in the repository and fail +everywhere else. Taking them as an argument also lets a caller judge a +declaration against a catalog it fetched, which is what a live-panel diagnostic +would do. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from span_panel_api_schema_1.description import optional_str + +CAPABILITY_PREFIX = "energy.ebus.capability." +"""The namespace a node's declared ``$type`` uses, and a catalog's ``capability``.""" + + +def capability_of(declared_type: str | None) -> str | None: + """The bare capability name behind a node's declared ``$type``. + + A node id is conventionally the capability name and every capture we hold + agrees, but the *type* is what the specification makes authoritative — a + publisher may name the node anything. Reading the id instead would work + until the day one did. + """ + if declared_type is None or not declared_type.startswith(CAPABILITY_PREFIX): + return None + return declared_type[len(CAPABILITY_PREFIX) :] or None + + +class Divergent(Enum): + """What a finding is about. + + ``UNCATALOGUED`` is terminal and exclusive: a property no catalog defines + has nothing to compare a unit or a datatype against, so it is reported once + as absent rather than three times as every field disagreeing with nothing. + The EVSE's ``config`` node is the case that makes the distinction matter — + it is not an eBus capability at all, and reporting its two properties as + unit mismatches would be a claim about a catalog that does not exist. + """ + + UNIT = "unit" + DATATYPE = "datatype" + UNCATALOGUED = "uncatalogued" + + +@dataclass(frozen=True) +class Declaration: + """The two fields of a property declaration this check compares. + + Both optional, on both sides: a catalog property may carry no unit + (``power-factor``, every enum), and so may a declaration. + """ + + unit: str | None + datatype: str | None + + +def declaration(raw: dict[str, object]) -> Declaration: + """Narrow one property definition — from a ``$description`` or a catalog. + + The same reader for both, because the two documents declare a property the + same way. That is the whole reason this comparison is possible. + """ + return Declaration(unit=optional_str(raw.get("unit")), datatype=optional_str(raw.get("datatype"))) + + +@dataclass(frozen=True) +class Divergence: + """One disagreement between a producer and a catalog. + + Identity is the whole tuple, deliberately. A divergence whose values change + — ``kW`` becoming ``mW`` — is a different divergence, and reads as the old + one disappearing and a new one arriving rather than as an entry that + silently goes on covering something nobody looked at. + + Producer-independent: the same mislabel seen in three captures of one panel + is one finding, not three. Which producers show it is recorded beside the + acknowledgement instead, so it can be checked without multiplying entries. + """ + + capability: str + property_id: str + kind: Divergent + declared: str | None + catalogued: str | None + + def __str__(self) -> str: + """The line a human reads in a failure, and the line they sort by. + + Sorting reports on this rather than on the tuple, because `Divergent` is + an Enum and not orderable, and because the text is what a reader is + scanning — a report ordered by a key that is not visible in it reads as + unordered. + """ + if self.kind is Divergent.UNCATALOGUED: + return f"{self.capability}/{self.property_id}: no catalog defines it" + return ( + f"{self.capability}/{self.property_id}: declared {self.kind.value} " + f"{self.declared!r}, catalog says {self.catalogued!r}" + ) + + +UNIT_FAMILIES: dict[str, frozenset[str]] = { + "energy": frozenset({"Wh", "kWh", "MWh", "J", "kJ", "MJ"}), +} +"""Catalog unit tokens that name a dimension rather than a unit. + +``soc/soe``, ``soc/total-energy-storage``, ``soc/loadup-headroom`` and +``info/nameplate-capacity`` are all ``unit: "energy"``, and the catalog prose is +explicit about why: the quantity is "reported in the device's native energy unit +(a BESS in kWh electrical, a water heater in Wh thermal) via `$unit`". The token +is an instruction to substitute, not a unit to match — so a publisher declaring +``kWh`` there is *conforming*, and a string compare against it would report the +one thing the specification asks for as the defect. + +Membership is enumerated rather than derived from an SI-prefix rule, for the +reason the whole design doc argues: a rule gets the case nobody thought about +wrong, quietly. A device declaring an energy unit outside this set is a finding +a human should see, which is what an empty match produces. + +Echoing the token itself (``unit: "energy"`` on the wire) is *not* membership, +and that is the second thing this catches: a publisher that copied the +placeholder out of the catalog instead of substituting its own unit. +""" + +CATALOGUED_CONCRETE_UNITS: frozenset[str] = frozenset( + {"%", "A", "Hz", "V", "VA", "VAh", "W", "Wh", "kA", "min", "var", "varh"} +) +"""Every non-abstract unit token the vendored catalogs currently use. + +Pinned so that a token arriving upstream has to be classified by a human before +it is compared: `unclassified_units` fails on anything that is in neither this +set nor `UNIT_FAMILIES`. Without it, a new abstract family — ``power``, say — +would be string-compared against every concrete unit a publisher substitutes and +report the whole family as broken, which is exactly the false finding this +module's family rule exists to prevent. + +Not a list of legal *wire* units. A publisher may declare any unit it likes; +this is only the vocabulary of the reference side. +""" + + +def unclassified_units(catalogued: frozenset[str]) -> frozenset[str]: + """Catalog unit tokens this module has no classification for. + + The guard on the guard: the family rule is only sound while every token it + might meet is known to be either concrete or a dimension. + """ + return catalogued - CATALOGUED_CONCRETE_UNITS - frozenset(UNIT_FAMILIES) + + +def unit_agrees(declared: str | None, catalogued: str | None) -> bool: + """Does a declared unit satisfy the catalogued one? + + Three rules, in the order they apply: + + 1. A catalog property with no unit expects a declaration with none. A unit + appearing where the reference carries none is as much a disagreement as + the wrong unit — it says the two sides disagree about whether the + quantity is dimensioned at all. + 2. An abstract family is satisfied by any member of the family, and by + nothing else — including the family token itself. + 3. Everything else is an exact match. + """ + if catalogued is None: + return declared is None + members = UNIT_FAMILIES.get(catalogued) + if members is None: + return declared == catalogued + return declared is not None and declared in members + + +def compare(capability: str, property_id: str, declared: Declaration, catalogued: Declaration | None) -> list[Divergence]: + """Judge one declared property against its catalog definition. + + ``catalogued`` is None when no catalog defines the property — either the + capability has no catalog at all (``config``) or the catalog does not carry + this name (``status/wifi-ssid``). Both produce a single ``UNCATALOGUED`` + finding and stop: there is no reference to compare against, and saying so + once is the honest report. + """ + if catalogued is None: + return [Divergence(capability, property_id, Divergent.UNCATALOGUED, None, None)] + + found: list[Divergence] = [] + if not unit_agrees(declared.unit, catalogued.unit): + found.append(Divergence(capability, property_id, Divergent.UNIT, declared.unit, catalogued.unit)) + if declared.datatype != catalogued.datatype: + found.append(Divergence(capability, property_id, Divergent.DATATYPE, declared.datatype, catalogued.datatype)) + return found diff --git a/tests/test_catalog_divergence.py b/tests/test_catalog_divergence.py new file mode 100644 index 0000000..e49577c --- /dev/null +++ b/tests/test_catalog_divergence.py @@ -0,0 +1,685 @@ +"""The registry used as a validator — what a panel *declares* against what the +catalogs *define*. + +`test_schema_one_conformance.py` asks whether every name this adapter reads is +one the specification carries. That is a question about vocabulary, and it is +answered by presence: a catalog exists, the property is in it, done. It never +opens the definition. + +This asks the next question, which is the one that corrupts readings when the +answer is wrong: **does the producer's declared `unit` and `datatype` for a +property agree with the catalog's?** Agreement is silence. Disagreement is a +finding, and is never resolved silently in either direction — the wire is not +"fixed" to match the catalog, and the catalog is not assumed to be right. The +one case in this repository's history was found by a person noticing that a +sibling device declared the same quantity differently; `meter/active-power` +labelled `kW` while the values were watts, a 1000x error that shipped. That is +what this makes mechanical. + +**Both producers are the subject.** The v1.0 side declares its capability nodes +on the wire, so the catalog for a property is whatever the node's `$type` names. +The flat schema document has no capability nodes at all — it predates them — so +its properties are joined to the catalogued vocabulary through the one thing the +two adapters already agree on: the snapshot field path each fills. The join is +required to agree on the property *name* as well, so a pre-catalog **rename** +(`dipole` for `breaker/poles`, `l1-voltage` for `meter/voltage-a`) is left out +rather than reported as a datatype divergence. A rename is a major-version event +under the eBus contract and is handled by having two adapters; it is not a +mislabel. + +**The register is not a suppression list.** An entry is a human saying "SPAN +ships this, we have looked at it, and we compensate" — with what the wire says, +what the catalog says, where it is observed, why, and when. It fails in both +directions like every other baseline here: a new divergence fails until somebody +records it, and a recorded divergence that has *disappeared* fails until its line +is removed. The second direction is what makes the register self-cleaning when a +firmware or a catalog is fixed. +""" + +from __future__ import annotations + +from collections.abc import Iterator, Mapping, Sequence +import copy +from dataclasses import dataclass +import json +from pathlib import Path + +from span_panel_api import reference_payloads as flat_payloads +from span_panel_api_schema_0.field_metadata import _PROPERTY_FIELD_MAP as _FLAT_FIELD_MAP +from span_panel_api_schema_1 import reference_payloads as tree_payloads +from span_panel_api_schema_1.catalog import ( + CATALOGUED_CONCRETE_UNITS, + UNIT_FAMILIES, + Declaration, + Divergence, + Divergent, + capability_of, + compare, + declaration, + unclassified_units, + unit_agrees, +) +from span_panel_api_schema_1.const import NODE_METER +from span_panel_api_schema_1.description import nodes as declared_nodes, optional_str, properties as declared_properties +from span_panel_api_schema_1.field_metadata import ( + _DOWNSTREAM_LUGS_FIELDS, + _PROPERTY_FIELD_MAP as _ONE_FIELD_MAP, + _UPSTREAM_LUGS_FIELDS, +) + +_SPEC = Path(__file__).parent.parent / "packages" / "schema-1" / "spec" +_CATALOGS = _SPEC / "catalogs" +_SIMULATOR_TREE = _SPEC / "fixtures" / "simulator_tree.json" +_SIMULATOR_WIRE = _SPEC / "fixtures" / "simulator_wire.json" + +SIMULATOR_TREE = "simulator-tree" +SIMULATOR_WIRE = "simulator-wire" +REFERENCE_TREE = "reference-tree" +FLAT_SCHEMA = "flat-schema" + + +# --------------------------------------------------------------------------- +# The acknowledged-divergence register +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Acknowledged: + """One divergence a human has read and decided to live with. + + `observed_in` is part of what is checked, not annotation. A divergence that + moves between producers — flat's mislabel being fixed while a v1.0 capture + starts showing it — is a different situation than the one that was recorded, + and an entry that went on covering it would be exactly the suppression this + register is not. + + `recorded` is the date the entry was written, so a line nobody has revisited + since the firmware it describes shipped is visible as such. + """ + + observed_in: tuple[str, ...] + reason: str + recorded: str + + +_REGISTER: dict[Divergence, Acknowledged] = { + Divergence("meter", "active-power", Divergent.UNIT, "kW", "W"): Acknowledged( + observed_in=(FLAT_SCHEMA,), + reason=( + "The flat schema document labels circuit active power `kW`; real panels publish watts, " + "and following the label reintroduces the 1000x error 1eef0dc removed after checking " + "against hardware. The consumer reads it as W deliberately -- " + "`test_circuit_active_power_unit_still_disagrees_with_the_schema` in " + "test_schema_provenance.py holds that side of it, against the schema. This line holds " + "the other side, against the catalog, which is what makes the disagreement a measured " + "fact about two producers rather than a comment in one test. v1.0 declares `W` and does " + "not carry the defect, which is why only the flat producer is observed here." + ), + recorded="2026-08-20", + ), + Divergence("info", "model", Divergent.DATATYPE, "enum", "string"): Acknowledged( + observed_in=(REFERENCE_TREE, SIMULATOR_TREE, SIMULATOR_WIRE), + reason=( + "The catalog types `model` as `string` while its own description invites a publisher to " + "advertise the valid set 'via Homie `$format` on the property' -- which Homie 5 permits " + "only on an `enum`. The two halves of the catalog entry disagree, and SPAN followed the " + "description: the enclosure declares its model as an enum over the five load-centre " + "configurations (MAIN_16..MLO_48), and every other device class declares the plain " + "string. Nothing is compensated in code, because an enum payload is text either way and " + "`battery.model` / `pv.model` are read as text. Recorded rather than silenced because " + "the catalog is the side that should move: raise it upstream so `model` is typed the way " + "its description already describes." + ), + recorded="2026-08-20", + ), +} + + +# --------------------------------------------------------------------------- +# The catalogued reference +# --------------------------------------------------------------------------- + + +def _json_object(path: Path) -> dict[str, object]: + with path.open(encoding="utf-8") as handle: + loaded: object = json.load(handle) + assert isinstance(loaded, dict), f"{path} is not a JSON object" + return {str(key): value for key, value in loaded.items()} + + +def _objects(raw: object) -> dict[str, dict[str, object]]: + """The object-valued members of a JSON object, keyed by name.""" + if not isinstance(raw, dict): + return {} + return {str(key): value for key, value in raw.items() if isinstance(value, dict)} + + +def _catalogued() -> dict[str, dict[str, Declaration]]: + """Every vendored catalog, keyed by the capability it declares itself to be. + + By the `capability` field rather than the file name, because that is the + name a node's `$type` carries. The two agree today and the convention is + that they always will; keying on the one that is matched against removes the + convention from the load-bearing path. + """ + catalogued: dict[str, dict[str, Declaration]] = {} + for path in sorted(_CATALOGS.glob("*.json")): + document = _json_object(path) + capability = capability_of(optional_str(document.get("capability"))) + assert capability is not None, f"{path.name} declares no capability in the eBus namespace" + catalogued[capability] = { + property_id: declaration(definition) for property_id, definition in _objects(document.get("properties")).items() + } + return catalogued + + +# --------------------------------------------------------------------------- +# What the producers declare +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Declared: + """One property declaration, normalised across producers.""" + + capability: str + property_id: str + declaration: Declaration + + +def _from_description(description: dict[str, object]) -> Iterator[Declared]: + """Every property one `$description` declares on a capability node. + + Nodes whose `$type` is outside the eBus capability namespace are skipped — + there is nothing to look a catalog up by. `test_every_captured_node_names_a_capability` + pins that this never happens in the captures we hold, so the skip cannot + quietly shrink the surface being checked. + """ + for node in declared_nodes(description).values(): + capability = capability_of(optional_str(node.get("type"))) + if capability is None: + continue + for property_id, definition in declared_properties(node).items(): + yield Declared(capability, property_id, declaration(definition)) + + +def _untyped_nodes(descriptions: Sequence[dict[str, object]]) -> list[str]: + """Node ids whose `$type` names no eBus capability.""" + return [ + node_id + for description in descriptions + for node_id, node in declared_nodes(description).items() + if capability_of(optional_str(node.get("type"))) is None + ] + + +def _tree_descriptions() -> list[dict[str, object]]: + """The simulator capture that is already a tree of parsed descriptions.""" + return list(_objects(_json_object(_SIMULATOR_TREE)).values()) + + +def _wire_descriptions(tree: Mapping[str, Mapping[str, str]]) -> list[dict[str, object]]: + """The descriptions inside a retained-topic capture. + + `$description` is a JSON *string* on the wire, which is the shape the two + wire captures are vendored in — and the shape a live broker replay has, so + this reader is the one a diagnostic would reuse. + """ + descriptions: list[dict[str, object]] = [] + for topics in tree.values(): + raw = topics.get("$description") + if raw is None: + continue + parsed: object = json.loads(raw) + assert isinstance(parsed, dict), "a captured $description is not a JSON object" + descriptions.append({str(key): value for key, value in parsed.items()}) + return descriptions + + +def _simulator_wire() -> Mapping[str, Mapping[str, str]]: + return { + device_id: {str(topic): str(payload) for topic, payload in topics.items()} + for device_id, topics in _objects(_json_object(_SIMULATOR_WIRE)).items() + } + + +# --------------------------------------------------------------------------- +# The flat producer, joined to the catalogued vocabulary +# --------------------------------------------------------------------------- + + +def _catalogued_spellings() -> dict[str, set[tuple[str, str]]]: + """Snapshot field path -> the `(capability, property)` v1.0 fills it from. + + Derived from the v1.0 metadata table plus the two lugs tables, which is + every route the parser has to a field path. Restating it would let the join + go on describing a mapping the parser had moved. + """ + spellings: dict[str, set[tuple[str, str]]] = {} + rows = [(node, property_id, path) for _, node, property_id, path in _ONE_FIELD_MAP] + rows += [(NODE_METER, property_id, path) for property_id, path in _UPSTREAM_LUGS_FIELDS + _DOWNSTREAM_LUGS_FIELDS] + for node, property_id, path in rows: + spellings.setdefault(path, set()).add((node, property_id)) + return spellings + + +def _flat_declared() -> list[Declared]: + """The flat schema document's properties, under their catalogued capability. + + The flat document is a real producer — captured from a panel on + `spanos2/r202603/05` — and it is where the one mislabel this whole check + exists for actually lives. It cannot be read the way a v1.0 tree is: it + declares properties per *device type*, with no capability node to look a + catalog up by. + + So the capability comes from the snapshot field the two adapters agree the + property fills, and the join is admitted only when both sides spell the + property the same. That second condition is what keeps this honest. Fifteen + flat properties reach a catalogued property under a *different* name -- + `dipole` for `breaker/poles`, `software-version` for `info/firmware-version`, + `shed-priority` for `load-shed/priority` -- and every one of those is a + rename rather than a mislabel. Comparing across a rename would report + `dipole`'s `boolean` against `poles`'s `integer` as a divergence, when what + it really shows is that flat asks a yes/no question where v1.0 publishes a + count. + """ + spellings = _catalogued_spellings() + declared: list[Declared] = [] + for device_type, properties in flat_payloads.homie_schema_types().items(): + for property_id, definition in _objects(properties).items(): + for path in (p for kind, name, p in _FLAT_FIELD_MAP if kind == device_type and name == property_id): + for capability, catalogued_name in sorted(spellings.get(path, set())): + if catalogued_name == property_id: + declared.append(Declared(capability, property_id, declaration(definition))) + return declared + + +# --------------------------------------------------------------------------- +# The survey +# --------------------------------------------------------------------------- + + +def _surface() -> dict[str, list[Declared]]: + """Every declaration this check judges, by producer.""" + return { + SIMULATOR_TREE: [d for description in _tree_descriptions() for d in _from_description(description)], + SIMULATOR_WIRE: [d for description in _wire_descriptions(_simulator_wire()) for d in _from_description(description)], + REFERENCE_TREE: [ + d + for description in _wire_descriptions(tree_payloads.parent_child_tree()) + for d in _from_description(description) + ], + FLAT_SCHEMA: _flat_declared(), + } + + +def _findings(surface: Mapping[str, Sequence[Declared]]) -> dict[Divergence, frozenset[str]]: + """Every divergence in a surface, with the producers that show it. + + Producer-independent identity: one mislabel published by a panel and + captured three ways is one finding. Which captures show it is the value, so + a register entry can be checked against it without being written three + times. + """ + catalogued = _catalogued() + found: dict[Divergence, set[str]] = {} + for producer, declarations in surface.items(): + for entry in declarations: + definition = catalogued.get(entry.capability, {}).get(entry.property_id) + for divergence in compare(entry.capability, entry.property_id, entry.declaration, definition): + found.setdefault(divergence, set()).add(producer) + return {divergence: frozenset(producers) for divergence, producers in found.items()} + + +def _divergences(surface: Mapping[str, Sequence[Declared]]) -> dict[Divergence, frozenset[str]]: + """Findings that are a disagreement about a definition, not an absence.""" + return { + divergence: producers + for divergence, producers in _findings(surface).items() + if divergence.kind is not Divergent.UNCATALOGUED + } + + +def _report(divergence: Divergence, producers: frozenset[str]) -> str: + return f"{divergence} [{', '.join(sorted(producers))}]" + + +# --------------------------------------------------------------------------- +# The register fails in both directions +# --------------------------------------------------------------------------- + + +def test_every_divergence_is_acknowledged() -> None: + """A producer declaring something the catalog contradicts stops the build. + + The direction that catches the next `kW`. What it wants is not a fix — the + right answer is often that the producer is right and the catalog is stale — + but a human decision, written down, with a date on it. + """ + surveyed = _divergences(_surface()) + unrecorded = sorted( + (_report(divergence, producers) for divergence, producers in surveyed.items() if divergence not in _REGISTER) + ) + + assert not unrecorded, ( + "declared definitions that disagree with the vendored catalogs:\n " + + "\n ".join(unrecorded) + + "\n\nDecide which side is wrong — the catalog is not automatically right — and record the " + "outcome in _REGISTER with a reason and a date. Do not change the wire reader to agree with " + "the catalog, or the catalog copy to agree with the wire." + ) + + +def test_every_acknowledgement_still_describes_a_real_divergence() -> None: + """The self-cleaning direction. + + When a firmware or a catalog is fixed, the entry describing the old + disagreement becomes a false statement about the producer — and a silent + one, because everything still passes. This turns it into a prompt to delete + the line, which is the only thing that keeps the register from becoming the + suppression list it must not be. + """ + surveyed = _divergences(_surface()) + stale = sorted( + f"{divergence} — recorded {entry.recorded}" for divergence, entry in _REGISTER.items() if divergence not in surveyed + ) + + assert not stale, ( + "recorded as acknowledged divergences but no producer declares them any more:\n " + + "\n ".join(stale) + + "\n\nThe disagreement is over. Delete the entry; its failing is good news." + ) + + +def test_every_acknowledgement_names_the_producers_that_still_show_it() -> None: + """Where a divergence lives is checked, not annotated. + + A mislabel fixed in one producer and appearing in another is a new + situation, not the one somebody signed off. Without this the entry would go + on covering it under a reason that had stopped being true. + """ + surveyed = _divergences(_surface()) + moved = sorted( + f"{divergence}: recorded in {sorted(entry.observed_in)}, observed in {sorted(surveyed[divergence])}" + for divergence, entry in _REGISTER.items() + if divergence in surveyed and frozenset(entry.observed_in) != surveyed[divergence] + ) + + assert not moved, ( + "acknowledged divergences no longer observed where they were recorded:\n " + + "\n ".join(moved) + + "\n\nRe-read the entry's reason before updating `observed_in` — a divergence changing " + "producers usually means the reason is out of date too." + ) + + +def test_every_acknowledgement_justifies_itself() -> None: + """A register line is a human's claim, and a claim needs its working. + + Cheap to assert and worth asserting, because the failure mode of a register + is a line added under deadline with `reason="known issue"`, which is a + suppression with extra syntax. + """ + thin = sorted(str(divergence) for divergence, entry in _REGISTER.items() if len(entry.reason) < 120) + assert not thin, f"acknowledgements with no real reason recorded: {thin}" + + undated = sorted(str(divergence) for divergence, entry in _REGISTER.items() if not entry.recorded.count("-") == 2) + assert not undated, f"acknowledgements with no ISO date: {undated}" + + misfiled = sorted( + str(divergence) + for divergence, entry in _REGISTER.items() + if set(entry.observed_in) - {SIMULATOR_TREE, SIMULATOR_WIRE, REFERENCE_TREE, FLAT_SCHEMA} + ) + assert not misfiled, f"acknowledgements naming a producer this check does not survey: {misfiled}" + + +# --------------------------------------------------------------------------- +# An absence is an absence, and is reported once +# --------------------------------------------------------------------------- + + +def test_a_property_no_catalog_defines_is_never_reported_as_a_mismatch() -> None: + """The EVSE `config` node is the case, and it is not a defect. + + `config` is not an eBus capability at all — the specification has no catalog + of that name, which `test_an_unvendored_node_is_one_the_specification_really_does_not_define` + checks against a real checkout, and both its properties are declared + extensions in `_SPAN_EXTENSIONS`. Comparing its `unit` against a catalog that + does not exist would report SPAN's own vocabulary as a mislabel, twice per + property. + + So an absence is terminal: reported once, as an absence, and never again as + a disagreement about a definition. + """ + findings = _findings(_surface()) + absent = {(d.capability, d.property_id) for d in findings if d.kind is Divergent.UNCATALOGUED} + mismatched = {(d.capability, d.property_id) for d in findings if d.kind is not Divergent.UNCATALOGUED} + + assert ("config", "max-charge-current") in absent, "the EVSE config node is no longer reported as uncatalogued" + assert ("config", "user-max-charge-current") in absent, "the EVSE config node is no longer reported as uncatalogued" + + both = sorted(absent & mismatched) + assert not both, f"reported as both absent from the catalog and disagreeing with it: {both}" + + for property_id in ("max-charge-current", "user-max-charge-current"): + reported = [d for d in findings if (d.capability, d.property_id) == ("config", property_id)] + assert len(reported) == 1, f"config/{property_id} reported {len(reported)} times: {[str(d) for d in reported]}" + + +# --------------------------------------------------------------------------- +# The rule that keeps an abstract unit from producing a false finding +# --------------------------------------------------------------------------- + + +def test_an_abstract_family_unit_is_satisfied_by_a_member_of_the_family() -> None: + """`unit: "energy"` is an instruction to substitute, not a unit to match. + + The catalog says `soc/soe` and `info/nameplate-capacity` are `energy`; the + BESS in every capture publishes `kWh`, which is the substitution the + specification asks for. A string compare would report conformance as the + defect — and it would do so on four of the sixty-odd properties this check + compares, which is enough noise to get the whole check turned off. + + Membership is what is satisfied, and only membership: echoing the token back + is not a substitution, and an energy unit nobody enumerated is a question for + a human rather than a pass. + """ + assert unit_agrees("kWh", "energy"), "the substitution the specification asks for must be silent" + assert unit_agrees("Wh", "energy"), "a water heater's thermal Wh is the same substitution" + assert not unit_agrees("energy", "energy"), "echoing the placeholder is not substituting a unit" + assert not unit_agrees("W", "energy"), "a power unit does not satisfy an energy dimension" + assert not unit_agrees(None, "energy"), "declaring no unit at all does not satisfy it either" + + assert unit_agrees("W", "W"), "a concrete unit is an exact match" + assert not unit_agrees("kW", "W"), "the mislabel this whole check exists for must not be excused" + assert unit_agrees(None, None), "a property neither side gives a unit is silent" + assert not unit_agrees("%", None), "a unit where the catalog carries none is a disagreement" + + +def test_a_node_outside_the_capability_namespace_resolves_to_no_capability() -> None: + """What a node's `$type` has to be before a catalog can be looked up for it. + + The namespace is the whole check on a name that arrives from a publisher: a + device type, a vendor extension or an empty string names no capability, and + `capability_of` says so rather than producing a bare word that would then + miss every catalog and be reported as an absence. The two are different + situations, and only one of them is a fact about the specification. + """ + assert capability_of("energy.ebus.capability.meter") == "meter" + assert capability_of("energy.ebus.capability.config") == "config", "an uncatalogued capability is still a capability" + assert capability_of("energy.ebus.device.circuit") is None, "a device type is not a capability" + assert capability_of("meter") is None, "a bare node id makes no claim about the namespace" + assert capability_of("energy.ebus.capability.") is None, "an empty suffix names nothing" + assert capability_of(None) is None + + +def test_a_finding_reads_as_the_sentence_a_human_has_to_act_on() -> None: + """The report line is the whole interface of this check. + + Everything above produces one of these two sentences, and a person reading a + failed build has nothing else to go on — so the two kinds have to be + distinguishable at a glance, and an absence must not be dressed up as a + disagreement with values it does not have. + """ + mismatch = Divergence("meter", "active-power", Divergent.UNIT, "kW", "W") + assert str(mismatch) == "meter/active-power: declared unit 'kW', catalog says 'W'" + + absent = Divergence("config", "max-charge-current", Divergent.UNCATALOGUED, None, None) + assert str(absent) == "config/max-charge-current: no catalog defines it" + + +def test_every_catalogued_unit_token_is_classified() -> None: + """The guard on the family rule. + + A unit token arriving in a vendored catalog that is neither a concrete unit + nor an enumerated dimension would be string-compared against whatever a + publisher substitutes, and report an entire new family as broken. That is + the false finding this module was written to avoid, so a new token has to be + classified by a human before it is compared against anything. + """ + catalogued = frozenset( + definition.unit for properties in _catalogued().values() for definition in properties.values() if definition.unit + ) + unclassified = sorted(unclassified_units(catalogued)) + + assert not unclassified, ( + f"unit tokens in the vendored catalogs that are neither concrete nor an enumerated family: {unclassified}. " + "Decide which, and add it to CATALOGUED_CONCRETE_UNITS or UNIT_FAMILIES in catalog.py." + ) + + assert "energy" in UNIT_FAMILIES, "the one abstract family this repository has met" + retired = sorted(CATALOGUED_CONCRETE_UNITS - catalogued) + assert not retired, ( + f"units pinned as catalogued but no catalog uses them any more: {retired}. " + "Drop them, so this set keeps describing the vendored vocabulary rather than a past one." + ) + + +# --------------------------------------------------------------------------- +# The check is actually looking at something +# --------------------------------------------------------------------------- + + +def test_every_producer_contributes_a_compared_surface() -> None: + """A survey that silently reads nothing passes every assertion above. + + The way this check dies is not a wrong answer, it is a reader that stops + finding declarations — a capture reshaped, a metadata table moved — after + which the register is a list of comments and the build is green. So the + surface is measured, and the four anchors that make the comparison worth + running are named. + """ + catalogued = _catalogued() + surface = _surface() + + for producer, declarations in surface.items(): + assert declarations, f"{producer} contributed no declarations at all" + + compared = { + (entry.capability, entry.property_id) + for declarations in surface.values() + for entry in declarations + if entry.property_id in catalogued.get(entry.capability, {}) + } + + for anchor in (("meter", "active-power"), ("soc", "soe"), ("info", "model"), ("breaker", "rating")): + assert anchor in compared, f"{anchor[0]}/{anchor[1]} is no longer being compared against its catalog" + + assert len(compared) >= 55, f"only {len(compared)} catalogued properties are being compared; the readers have narrowed" + + +def test_the_flat_join_still_reaches_the_known_mislabel() -> None: + """The flat producer is joined through two metadata tables, and both move. + + If either table drops the row that carries `circuit.instant_power_w`, the + join goes quiet and the `kW` mislabel stops being compared — with the + register entry still sitting there, describing a divergence nothing looks + for any more. `test_every_acknowledgement_still_describes_a_real_divergence` + would catch that as a stale entry, but it would read as good news rather + than as a broken join, so the join is asserted on its own. + """ + joined = {(entry.capability, entry.property_id) for entry in _flat_declared()} + assert ("meter", "active-power") in joined, "the flat schema's circuit active-power no longer reaches the meter catalog" + + respellings = {("breaker", "poles"), ("meter", "voltage-a"), ("info", "firmware-version"), ("load-shed", "priority")} + assert not (joined & respellings), ( + "the flat join now compares across a rename. A pre-catalog spelling of a catalogued property " + "is an adapter concern, not a mislabel, and comparing across it invents divergences." + ) + + +def test_every_captured_node_names_a_capability() -> None: + """`_from_description` skips a node whose `$type` names no capability. + + Nothing in a capture we hold does that, and pinning it here is what keeps + the skip from becoming a way for the surface to shrink unnoticed — a node + that lost its `$type` would drop off the comparison silently. + """ + descriptions = ( + _tree_descriptions() + _wire_descriptions(_simulator_wire()) + _wire_descriptions(tree_payloads.parent_child_tree()) + ) + untyped = sorted(set(_untyped_nodes(descriptions))) + + assert not untyped, f"captured nodes declaring no eBus capability type: {untyped}" + + +# --------------------------------------------------------------------------- +# Proof that it bites +# --------------------------------------------------------------------------- + + +def test_a_relabelled_unit_in_a_capture_is_reported() -> None: + """Mutation proof, on the exact shape of the defect this exists to catch. + + A capture is copied, one circuit's `meter/active-power` is relabelled `kW` + the way the flat schema has it, and the survey is re-run over the copy. The + real captures are untouched — the point is that the reader, not a fixture, + is what notices. + """ + mutated = copy.deepcopy(_json_object(_SIMULATOR_TREE)) + relabelled = 0 + for device in _objects(mutated).values(): + meter = declared_nodes(device).get(NODE_METER, {}) + for property_id, definition in declared_properties(meter).items(): + if property_id == "active-power" and definition.get("unit") == "W": + definition["unit"] = "kW" + relabelled += 1 + assert relabelled, "no captured device declares meter/active-power in W; the mutation proves nothing" + + surface = {SIMULATOR_TREE: [d for device in _objects(mutated).values() for d in _from_description(device)]} + reported = _divergences(surface) + + mislabel = Divergence("meter", "active-power", Divergent.UNIT, "kW", "W") + assert mislabel in reported, f"a relabelled unit was not reported; found {sorted(str(d) for d in reported)}" + assert reported[mislabel] == frozenset({SIMULATOR_TREE}), "the finding names the wrong producer" + + assert mislabel in _REGISTER, "the register happens to carry this one, from the flat schema" + assert _REGISTER[mislabel].observed_in == (FLAT_SCHEMA,), ( + "which is why the same divergence arriving from a v1.0 capture fails " + "test_every_acknowledgement_names_the_producers_that_still_show_it rather than passing quietly" + ) + + +def test_a_relabelled_datatype_in_a_capture_is_reported() -> None: + """The other field, mutated the same way. + + `unit` and `datatype` are compared by different rules — one family-aware, + one exact — so proving one bites does not prove the other does. + """ + mutated = copy.deepcopy(_json_object(_SIMULATOR_TREE)) + relabelled = 0 + for device in _objects(mutated).values(): + breaker = declared_nodes(device).get("breaker", {}) + for property_id, definition in declared_properties(breaker).items(): + if property_id == "rating": + definition["datatype"] = "string" + relabelled += 1 + assert relabelled, "no captured device declares breaker/rating; the mutation proves nothing" + + surface = {SIMULATOR_TREE: [d for device in _objects(mutated).values() for d in _from_description(device)]} + reported = _divergences(surface) + + assert ( + Divergence("breaker", "rating", Divergent.DATATYPE, "string", "integer") in reported + ), f"a relabelled datatype was not reported; found {sorted(str(d) for d in reported)}" From 13ba507084997d543cd276ac6ac8d0de71391370 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:09:25 -0700 Subject: [PATCH 091/115] feat(schema-1): report what a panel declares that this adapter reads nothing from `test_declared_but_unread` in the integration already asks this question and answers it by experiment. It is the right check and it is fixture-bound: a real panel that starts publishing a property fails nothing until somebody recaptures. This is the same question asked of the tree in front of the user. `build_field_metadata` now returns a second kind of row alongside the curated ones. For every property a device's `$description` declares that this adapter addresses nowhere, a row under the `discovered.` namespace carries the declared `datatype`, the declared `unit`, and whether a value has been published for it. Never the value: these rows exist to be forwarded in a consumer's diagnostics, which leave the machine they were generated on, and a consumer's redaction is key-based and knows nothing about wire names. Additive by construction. No `SchemaAdapter` member and no `ADAPTER_CONTRACT` bump -- `_derive_required_members` makes every public protocol member required of every adapter distribution, so adding one would reject every built wheel. An adapter emitting no such rows is indistinguishable from one built before the namespace existed, and a consumer that never partitions sees exactly the curated rows it saw before. Namespaced rather than flagged because the failure being prevented is a silent one. A consumer's curated inventories are keyed by snapshot field path, and a discovered row reaching one would read as a produced field nothing renders -- the shape of a real defect. **The report is only as good as the enumerations behind it, so they are proved.** "Addressed" comes from four tables: `_PROPERTY_FIELD_MAP`, the lugs direction tables, the charge-limit resolution, and the new `_CONSUMED_WITHOUT_A_ROW` -- the forty properties the snapshot mapper reads that carry no metadata row because they are identity, topology, or a qualifier rather than a reading. Without that fourth table the only enumeration of what schema_1 reads was the metadata map, which is a third of it, and the report would have been 52 rows of which 42 were false. A stale entry there fails *silently*, by keeping a property out of the report, and a smaller report looks exactly like a panel with nothing new on it. `test_schema_one_discovery.py` closes that by experiment: it republishes every property the reference tree declares with a legal different value, rebuilds the snapshot through the real mapper, and asserts both directions -- every claimed-read property moves a snapshot field, and every reported property moves none. Together those pin the output exactly. `_CONSUMED_OFF_SNAPSHOT` holds the three declarations consumed by a route no snapshot field can show; each names the code that reads it, and each fails the day its property does move a field, which is what keeps it from becoming an allowlist. The flat adapter emits none, deliberately: its metadata comes from the REST `types` document, the superset across all hardware rather than what one panel has, so "declared and unaddressed" there would describe the schema document. Against the reference tree the report is nine properties -- the `connection/ count` pair no producer publishes, the two deliberate `status` skips, the four redundant `*-device-type` echoes, and the PV serial held out of the device id. --- DEVELOPMENT.md | 19 + .../src/span_panel_api_schema_1/const.py | 10 + .../span_panel_api_schema_1/field_metadata.py | 220 ++++++- src/span_panel_api/__init__.py | 10 + src/span_panel_api/models.py | 68 +++ tests/test_public_api_unchanged.py | 9 + tests/test_schema_one_discovery.py | 543 ++++++++++++++++++ 7 files changed, 878 insertions(+), 1 deletion(-) create mode 100644 tests/test_schema_one_discovery.py diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index c87dae8..b764648 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -108,6 +108,25 @@ Two rules keep it from producing false findings: echoing the token back is not. Membership is enumerated in `catalog.py`'s `UNIT_FAMILIES`, and a catalog unit token that is neither a known family nor a known concrete unit fails until a human classifies it. - **An absence is terminal.** A property no catalog defines — the EVSE's `config` node, which is not an eBus capability at all — is reported once as absent and never as a unit or datatype mismatch against a definition that does not exist. +### What a panel declares that this library reads nothing from + +`schema_1`'s `build_field_metadata` returns a second kind of row alongside the curated ones: for every property a device's `$description` declares that the adapter addresses nowhere, a row under the `discovered.` namespace carrying the declared `datatype`, +the declared `unit`, and whether a value has been published for it. Never the value — these rows exist to be forwarded in a consumer's diagnostics, which leave the machine they were generated on. + +It is additive by construction. There is no new `SchemaAdapter` member and no `ADAPTER_CONTRACT` bump: `_derive_required_members` makes every public protocol member required of every adapter distribution, so adding one would reject every built wheel. An +adapter that emits no such rows is indistinguishable from one built before the namespace existed. + +The flat adapter emits none, deliberately. Its metadata comes from the REST `types` document, which the migration guide describes as the superset across all hardware rather than what one panel has — so "declared and unaddressed" there would describe the +schema document and could not answer the question this exists to ask. + +**The report is only as good as the enumerations behind it, so they are proved rather than trusted.** The adapter decides "addressed" from four tables: `_PROPERTY_FIELD_MAP`, the lugs direction tables, the charge-limit resolution, and +`_CONSUMED_WITHOUT_A_ROW` — the properties the snapshot mapper reads that carry no metadata row because they are identity, topology, or a qualifier rather than a reading. A stale entry in the last of those fails _silently_, by keeping a property out of the +report, and a smaller report looks exactly like a panel with nothing new on it. + +`test_schema_one_discovery.py` closes that by experiment: it republishes every property the reference tree declares with a legal different value, rebuilds the snapshot through the real mapper, and asserts both directions — every claimed-read property moves +a snapshot field, and every reported property moves none. `_CONSUMED_OFF_SNAPSHOT` holds the three declarations consumed by a route no snapshot field can show (tier-1 dispatch, the shadowed islanding tier, the unreached feedthrough branch); each names the +code that reads it, and each fails the day its property does move a field. + ## Linting and Formatting Pre-commit hooks run automatically on commit. To run all hooks manually: diff --git a/packages/schema-1/src/span_panel_api_schema_1/const.py b/packages/schema-1/src/span_panel_api_schema_1/const.py index 186b602..9b468c1 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/const.py +++ b/packages/schema-1/src/span_panel_api_schema_1/const.py @@ -10,6 +10,16 @@ # -- Device classes --------------------------------------------------------- +DEVICE_TYPE_PREFIX = "energy.ebus.device." +"""The common stem every eBus device type in this vocabulary carries. + +Stripped when a type has to be *named* rather than matched — the discovery +namespace's ``{device type}/{node}/{property}`` rows, which a maintainer reads +beside a capability catalog and a gap inventory that both spell the type short. +Matching still uses the full strings below, because a subtype check has to see +the whole type. +""" + TYPE_PANEL = "energy.ebus.device.distribution-enclosure" TYPE_CIRCUIT = "energy.ebus.device.circuit" TYPE_BESS = "energy.ebus.device.bess" diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index 396c1b5..7f0e42a 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -12,23 +12,31 @@ classes — ``meter`` on the panel is voltage, on a circuit is power and energy, on a lugs device is both currents. The REST ``deviceClasses`` document is the superset across all hardware; the description is what *this* panel actually has. + +**Two kinds of row, one map.** Alongside the curated rows this builds a +discovery row for every property the tree declares that this adapter addresses +nowhere, namespaced so a consumer can partition the two before it reads either. +See `build_discovery`. """ from __future__ import annotations from typing import TYPE_CHECKING -from span_panel_api.models import FieldMetadata +from span_panel_api.models import DiscoveredMetadata, FieldMetadata, discovery_path from span_panel_api_schema_1.charge_limit import ChargeLimitProperty, resolve_charge_limit from span_panel_api_schema_1.const import ( + DEVICE_TYPE_PREFIX, NODE_BREAKER, NODE_CONNECTION, NODE_DOOR, + NODE_GRID, NODE_INFO, NODE_LOAD_SHED, NODE_METER, NODE_PCS, NODE_POWER_FLOWS, + NODE_SHED, NODE_SHED_FORECAST, NODE_SOC, NODE_STATUS, @@ -40,6 +48,7 @@ TYPE_CIRCUIT, TYPE_EVSE, TYPE_LUGS, + TYPE_MID, TYPE_PANEL, TYPE_PV, ) @@ -210,6 +219,9 @@ def build_field_metadata(devices: list[DiscoveredDevice]) -> dict[str, FieldMeta metadata.update(_lugs_metadata(devices, upstream=True, fields=_UPSTREAM_LUGS_FIELDS)) metadata.update(_lugs_metadata(devices, upstream=False, fields=_DOWNSTREAM_LUGS_FIELDS)) metadata.update(_charge_limit_metadata(devices)) + # Namespaced, so a consumer partitions them out before it reads the map as + # an inventory of produced fields. See `build_discovery`. + metadata.update(build_discovery(devices)) return metadata @@ -371,3 +383,209 @@ def _lookup( if key.endswith(suffix) and key[: -len(suffix)].startswith(device_type): return value return None + + +_CONSUMED_WITHOUT_A_ROW: tuple[tuple[str, str, str], ...] = ( + # Properties the snapshot mapper reads that carry no `_PROPERTY_FIELD_MAP` + # row, and never will: a row exists to state a *reading's* unit and + # datatype, and none of these is a reading. They are build identity, the + # topology the mapper resolves roles from, and the qualifiers a consumer + # renders beside a reading rather than as one. + # + # Without this table `build_discovery` would report all of them as + # unaddressed, because the only enumeration of what schema_1 reads was the + # metadata map — which is a third of it. Every entry is proved by + # experiment: `test_schema_one_discovery` republishes each one against the + # reference tree and fails if the snapshot does not move, so an entry that + # stops being true is a red build rather than a property that quietly + # disappears from discovery. + # + # --- Panel identity, read by the snapshot's panel fields ----------------- + (TYPE_PANEL, NODE_INFO, "hardware-version"), + (TYPE_PANEL, NODE_INFO, "model"), + (TYPE_PANEL, NODE_INFO, "serial-number"), + (TYPE_PANEL, NODE_INFO, "vendor-name"), + # --- The PCS arbitration's inputs --------------------------------------- + # `import-limit`, `binding-constraint` and `active` are the result and carry + # rows; these thirteen explain the result. See the `pcs` rows above. + (TYPE_PANEL, NODE_PCS, "enabled"), + (TYPE_PANEL, NODE_PCS, "feed-import-limit"), + (TYPE_PANEL, NODE_PCS, "feed-import-limit-active"), + (TYPE_PANEL, NODE_PCS, "feed-import-limit-enablement"), + (TYPE_PANEL, NODE_PCS, "off-grid-import-limit"), + (TYPE_PANEL, NODE_PCS, "off-grid-import-limit-active"), + (TYPE_PANEL, NODE_PCS, "off-grid-import-limit-enablement"), + (TYPE_PANEL, NODE_PCS, "operator-import-limit"), + (TYPE_PANEL, NODE_PCS, "operator-import-limit-active"), + (TYPE_PANEL, NODE_PCS, "operator-import-limit-enablement"), + (TYPE_PANEL, NODE_PCS, "requested-import-limit"), + (TYPE_PANEL, NODE_PCS, "requested-import-limit-active"), + (TYPE_PANEL, NODE_PCS, "requested-import-limit-enablement"), + # --- The shed policy document and the forecast's refinements ------------- + (TYPE_PANEL, NODE_SHED, "policy"), + (TYPE_PANEL, NODE_SHED_FORECAST, "confidence"), + (TYPE_PANEL, NODE_SHED_FORECAST, "full-charge-time-to-priority-shed"), + (TYPE_PANEL, NODE_SHED_FORECAST, "full-charge-total-time-remaining"), + # --- Circuit topology and PCS membership -------------------------------- + (TYPE_CIRCUIT, NODE_CONNECTION, "feeds-device-id"), + (TYPE_CIRCUIT, NODE_PCS, "managed"), + (TYPE_CIRCUIT, NODE_PCS, "priority"), + # --- Lugs direction and the upstream device's link ----------------------- + # `info/direction` decides which lugs device is the main meter and which is + # the feedthrough, so it moves ten panel fields without being one. + (TYPE_LUGS, NODE_CONNECTION, "fed-by-device-id"), + (TYPE_LUGS, NODE_CONNECTION, "fed-by-device-status"), + (TYPE_LUGS, NODE_INFO, "direction"), + # --- MID ------------------------------------------------------------------ + # The islanding authority. Every field it feeds is device-card identity or + # a state string, so the whole device is here rather than in the map. + (TYPE_MID, NODE_GRID, "grid-forming-entity"), + (TYPE_MID, NODE_GRID, "grid-state"), + (TYPE_MID, NODE_GRID, "islanding-state"), + (TYPE_MID, NODE_INFO, "firmware-version"), + (TYPE_MID, NODE_INFO, "hardware-version"), + (TYPE_MID, NODE_INFO, "model"), + (TYPE_MID, NODE_INFO, "serial-number"), + (TYPE_MID, NODE_INFO, "vendor-name"), + # --- DER identity --------------------------------------------------------- + (TYPE_EVSE, NODE_INFO, "firmware-version"), + (TYPE_EVSE, NODE_INFO, "model"), + (TYPE_EVSE, NODE_INFO, "serial-number"), + (TYPE_EVSE, NODE_INFO, "vendor-name"), + (TYPE_PV, NODE_INFO, "firmware-version"), +) +"""Declarations the mapper reads into the snapshot without a metadata row. + +The charge-current pair is deliberately absent: which node and which property +carry it is the charger's choice, so `build_discovery` resolves it through +`resolve_charge_limit` exactly as `_charge_limit_metadata` does, rather than +naming one spelling here and leaving the other reported as unaddressed. +""" + +_CONSUMED_OFF_SNAPSHOT: dict[tuple[str, str, str], str] = { + (TYPE_PANEL, NODE_INFO, "data-model-version"): ( + "tier-1 adapter dispatch (span_panel_api.dispatch) — it chooses which adapter " + "parses the tree, so it is consumed before any snapshot exists" + ), + (TYPE_PANEL, NODE_SHED, "asserted-islanding-state"): ( + "tier 2 of resolve_islanding_state (panel.py), shadowed wherever a MID answers " + "at tier 1, and the write target of set_dominant_power_source_topic" + ), + (TYPE_LUGS, NODE_CONNECTION, "feeds-device-id"): ( + "the downstream-lugs feedthrough branch of resolve_relative_position " + "(devices.py), which no producer currently reaches" + ), +} +"""Declarations this library reads by a route no snapshot field can show. + +The category the republish experiment cannot measure, and therefore the one at +risk of becoming an allowlist. It is held to the opposite assertion instead: +`test_an_off_snapshot_route_that_became_observable_must_be_retired` fails the +moment one of these does move a snapshot field, because at that point the route +is no longer the only thing consuming it and the entry is hiding a real reader. + +Three, and each names the code that reads it. The consumer-side mirror is +`_INTERNAL_ROUTES` in the integration's `test_declared_but_unread`; they agree +because they are answering the same question of the same tree, not because +either copies the other. +""" + +_ADDRESSED: frozenset[tuple[str, str, str]] = ( + frozenset((device_type, node_id, property_id) for device_type, node_id, property_id, _ in _PROPERTY_FIELD_MAP) + | frozenset( + (TYPE_LUGS, NODE_METER, property_id) for property_id, _ in (*_UPSTREAM_LUGS_FIELDS, *_DOWNSTREAM_LUGS_FIELDS) + ) + | frozenset(_CONSUMED_WITHOUT_A_ROW) + | frozenset(_CONSUMED_OFF_SNAPSHOT) +) +"""Every ``(device type, node, property)`` this library addresses, from all four tables. + +Derived rather than restated, so a new `_PROPERTY_FIELD_MAP` row leaves +discovery without anyone remembering to. The charge-current pair is added per +charger at build time; see `build_discovery`. +""" + + +def build_discovery(devices: list[DiscoveredDevice]) -> dict[str, DiscoveredMetadata]: + """Metadata rows for every property this tree declares that nothing here reads. + + The runtime half of the declared-but-unread question. A vendored capture can + only answer it for the panel that was captured; a panel in the field that + starts publishing a property fails nothing and tells nobody until someone + recaptures. These rows put the same answer in front of a maintainer for the + panel actually in front of the user. + + Keyed under `DISCOVERY_NAMESPACE`, never as a snapshot field path, because a + consumer's curated inventories are keyed by field path and a discovered row + reaching one of them would read as a produced field nothing renders — which + is the shape of a real defect. The namespace makes the partition one string + test applied once. + + **Declarations only.** A row carries the property's declared unit and + datatype and whether a value has arrived. It never carries the value: these + rows are built to be forwarded in consumer diagnostics, which leave the + machine they were generated on. + + Emitted by this adapter alone. schema_0 builds its metadata from the REST + ``types`` document, which the migration guide describes as the superset + across all hardware rather than what one panel has — so "declared and + unaddressed" there would describe the schema document and could not answer + the question this exists to ask. + """ + addressed = set(_ADDRESSED) + for device in devices: + evse_type = declared_type(device) + if not evse_type.startswith(TYPE_EVSE): + continue + surface = resolve_charge_limit(device) + if surface is None: + continue + for declaration in (surface.limit, surface.ceiling): + if declaration is not None: + addressed.add((evse_type, surface.node, declaration.property_id)) + + declarations: dict[str, tuple[str | None, str]] = {} + valued: set[str] = set() + for device in devices: + device_type = declared_type(device) + if not device_type: + continue + for node_id, node in declared_nodes(device.description or {}).items(): + for property_id, definition in declared_properties(node).items(): + if _addressed_by(addressed, device_type, node_id, property_id): + continue + path = discovery_path(_short_type(device_type), node_id, property_id) + declarations.setdefault( + path, + (optional_str(definition.get("unit")), str(definition.get("datatype") or "string")), + ) + if device.get_property(node_id, property_id) is not None: + valued.add(path) + + return { + path: DiscoveredMetadata(unit=unit, datatype=datatype, retained=path in valued) + for path, (unit, datatype) in declarations.items() + } + + +def _short_type(device_type: str) -> str: + """The device type as the capability catalog and the gap inventories spell it.""" + if device_type.startswith(DEVICE_TYPE_PREFIX): + return device_type[len(DEVICE_TYPE_PREFIX) :] + return device_type + + +def _addressed_by(addressed: set[tuple[str, str, str]], device_type: str, node_id: str, property_id: str) -> bool: + """Whether any addressed row covers this declaration, subtypes included. + + Carries `_lookup`'s subtype rule for the same reason it exists there: eBus + device types are hierarchical and a subtype carries its parent's properties, + so a device typed ``X.Y`` is served by a row written for ``X``. Without it a + panel that subtypes its lugs devices would report every mapped lugs property + as newly discovered — which is the false positive that would teach a + maintainer to stop reading this. + """ + return any( + node == node_id and prop == property_id and (device_type == mapped_type or device_type.startswith(f"{mapped_type}.")) + for mapped_type, node, prop in addressed + ) diff --git a/src/span_panel_api/__init__.py b/src/span_panel_api/__init__.py index 6874332..872542e 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -31,6 +31,8 @@ ) from .factory import create_span_client from .models import ( + DISCOVERY_NAMESPACE, + DiscoveredMetadata, FieldMetadata, HomieSchemaTypes, SpanBatterySnapshot, @@ -43,6 +45,7 @@ V2AuthResponse, V2HomieSchema, V2StatusInfo, + is_discovery_path, ) from .mqtt import MqttClientConfig, SpanMqttClient from .phase_validation import ( @@ -80,6 +83,13 @@ # Metadata "FieldMetadata", "HomieSchemaTypes", + # Added 2026-08-20: runtime discovery. Purely additive -- an adapter that + # emits no discovered rows is indistinguishable from one built before the + # namespace existed, and a consumer that never partitions on the namespace + # sees exactly the curated rows it saw before. + "DISCOVERY_NAMESPACE", + "DiscoveredMetadata", + "is_discovery_path", # Snapshots "SpanBatterySnapshot", "SpanCircuitSnapshot", diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 4f6724e..933ff63 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -429,6 +429,74 @@ class FieldMetadata: """ +DISCOVERY_NAMESPACE = "discovered" +"""Field-path namespace for properties an adapter declares and does not address. + +Rows under this namespace are **not** curated fields. They name a wire property +the panel's own ``$description`` declares and that the running adapter maps to +no snapshot field and reads nowhere — the runtime half of the +declared-but-unread question, asked of the panel in front of the user rather +than of a vendored capture. + +Namespaced rather than flagged because the failure this prevents is a *silent* +one. A consumer's curated inventories are keyed by snapshot field path +(``panel.``, ``circuit.``, ``battery.``, …), and a discovered row that reached +one of them would be read as a produced field nothing renders, which is the +shape of a real defect. A distinct prefix means the partition is a string test +any consumer can apply once, before any other question is asked of the map, and +that a discovered row landing in a curated set is a visible error rather than an +extra entry nobody notices. + +The path body is ``{device type}/{node}/{property}``, the same rendering the +capability catalogs and the consumer-side gap inventories use, so a maintainer +reading a row can look it up without translating it. +""" + +_DISCOVERY_PREFIX = f"{DISCOVERY_NAMESPACE}." + + +def discovery_path(device_type: str, node_id: str, property_id: str) -> str: + """The namespaced field path for one declared-but-unaddressed property. + + `device_type` is the eBus type with its common ``energy.ebus.device.`` + prefix already stripped by the caller — the adapter owns that vocabulary, + and this function owns only the namespace. + """ + return f"{_DISCOVERY_PREFIX}{device_type}/{node_id}/{property_id}" + + +def is_discovery_path(field_path: str) -> bool: + """Whether `field_path` names a discovered property rather than a curated field.""" + return field_path.startswith(_DISCOVERY_PREFIX) + + +@dataclass(frozen=True, slots=True) +class DiscoveredMetadata(FieldMetadata): + """A metadata row for a property the panel declares and the adapter does not read. + + Only ever appears under `DISCOVERY_NAMESPACE`. Carries the declaration and + nothing else: the property's declared ``unit`` and ``datatype``, and whether + the panel has published a value for it — never the value. These rows exist + to be forwarded to a maintainer through consumer diagnostics, which leave + the machine they were generated on, so the type deliberately has no member a + reading could be put in. + + ``resolved`` is always True here and says nothing new: a discovered row + exists *because* a device declared the property, so the device is found by + construction. `retained` is the question that has an answer. + """ + + retained: bool = False + """Whether any device declaring this property has published a value for it. + + False is the declared-but-never-valued case panelbench's + ``test_declared_but_unvalued`` looks for from the producer side — a property + the firmware advertises and never fills. Distinguishing it matters for the + only decision these rows inform: a declaration with no traffic behind it is + not a surface worth curating yet. + """ + + @dataclass(frozen=True, slots=True) class V2AuthResponse: """Response from POST /api/v2/auth/register.""" diff --git a/tests/test_public_api_unchanged.py b/tests/test_public_api_unchanged.py index c4de57a..e8c2c8c 100644 --- a/tests/test_public_api_unchanged.py +++ b/tests/test_public_api_unchanged.py @@ -31,6 +31,15 @@ # Metadata "FieldMetadata", "HomieSchemaTypes", + # Added 2026-08-20: runtime discovery -- the namespace an adapter puts + # declared-but-unaddressed properties under, the row type it puts there, and + # the predicate a consumer partitions with. Purely additive: an adapter that + # emits none of these rows is indistinguishable from one built before the + # namespace existed, and a consumer that never partitions sees exactly the + # curated rows it saw before. + "DISCOVERY_NAMESPACE", + "DiscoveredMetadata", + "is_discovery_path", # Snapshots "SpanBatterySnapshot", "SpanCircuitSnapshot", diff --git a/tests/test_schema_one_discovery.py b/tests/test_schema_one_discovery.py new file mode 100644 index 0000000..b1267c6 --- /dev/null +++ b/tests/test_schema_one_discovery.py @@ -0,0 +1,543 @@ +"""What the reference tree declares that this adapter reads nothing from. + +`build_discovery` answers that question at runtime, for the panel in front of +the user, by subtracting four enumerations of what schema_1 addresses from what +the tree declares. Three of those enumerations are hand-written, so the answer +is only as good as they are — and a stale entry fails *silently*, by keeping a +property out of discovery rather than by raising. + +So every entry is checked by the same experiment the consumer-side gate uses: +republish one declared property with a legal different value, rebuild the +snapshot through the real mapper, and see whether any snapshot field moved. That +is a fact about the code rather than about a table, and it is what makes the +discovery output mean "nothing here reads this" instead of "nobody wrote it +down". + +The two directions are asserted separately because they fail differently. An +entry in `_CONSUMED_WITHOUT_A_ROW` that moves nothing is a property that has +silently dropped out of discovery. A discovered row that *does* move something +is a false positive, and false positives are what teach a maintainer to stop +reading a report. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +import dataclasses +from functools import lru_cache +import json + +from ebus_sdk.homie import DiscoveredDevice +import pytest + +from span_panel_api.models import DiscoveredMetadata, SpanPanelSnapshot, is_discovery_path +from span_panel_api_schema_1 import field_metadata as field_metadata_module +from span_panel_api_schema_1.const import ( + DEVICE_TYPE_PREFIX, + NODE_METER, + NODE_STATUS, + TYPE_CIRCUIT, + TYPE_LUGS, + TYPE_PANEL, +) +from span_panel_api_schema_1.field_metadata import ( + _ADDRESSED, + _CONSUMED_OFF_SNAPSHOT, + _CONSUMED_WITHOUT_A_ROW, + _PROPERTY_FIELD_MAP, + build_discovery, + build_field_metadata, +) +from span_panel_api_schema_1.reference_payloads import ( + device_from_topics, + devices_from_tree, + parent_child_tree, +) +from span_panel_api_schema_1.snapshot import build_snapshot + +PANEL_DEVICE_ID = "example-40t-001" +"""The enclosure in the reference capture. Every other device is its child.""" + +Tree = dict[str, dict[str, str]] +Declaration = tuple[str, str, str] +"""``(device type, node, property)`` — the granularity every table here uses.""" + + +# --- the tree, and the experiment over it ---------------------------------- + + +def _tree() -> Tree: + """A mutable, one-level-deep copy of the capture. One topic is one string.""" + return {device_id: dict(topics) for device_id, topics in parent_child_tree().items()} + + +def _devices(tree: Tree) -> list[DiscoveredDevice]: + return [device_from_topics(device_id, topics) for device_id, topics in tree.items()] + + +def _snapshot(tree: Tree) -> SpanPanelSnapshot: + panel = device_from_topics(PANEL_DEVICE_ID, tree[PANEL_DEVICE_ID]) + children = [device_from_topics(device_id, topics) for device_id, topics in tree.items() if device_id != PANEL_DEVICE_ID] + return build_snapshot(panel, children) + + +def _mapping(value: object) -> Mapping[str, object]: + if not isinstance(value, Mapping): + return {} + return {str(key): item for key, item in value.items()} + + +def _text(value: object) -> str: + return value if isinstance(value, str) else "" + + +def _path(declaration: Declaration) -> str: + """The discovery path a declaration would be reported under.""" + device_type, node_id, property_id = declaration + return f"discovered.{device_type.removeprefix(DEVICE_TYPE_PREFIX)}/{node_id}/{property_id}" + + +def _record(fields: dict[str, str], prefix: str, obj: object) -> None: + if not dataclasses.is_dataclass(obj) or isinstance(obj, type): + return + for field in dataclasses.fields(obj): + fields[f"{prefix}.{field.name}"] = repr(getattr(obj, field.name)) + + +def _snapshot_fields(snapshot: SpanPanelSnapshot) -> dict[str, str]: + """Flatten a snapshot to ``{path: repr(value)}``, keyed per instance. + + The circuit and EVSE maps are keyed by their own ids so two instances cannot + mask each other's change, and values are held as `repr` so the comparison is + a plain string diff whatever a field holds. + """ + fields: dict[str, str] = {} + for field in dataclasses.fields(snapshot): + value = getattr(snapshot, field.name) + if field.name in {"circuits", "evse"}: + for key, item in value.items(): + _record(fields, f"{field.name}@{key}", item) + elif dataclasses.is_dataclass(value) and not isinstance(value, type): + _record(fields, field.name, value) + else: + fields[f"panel.{field.name}"] = repr(value) + return fields + + +def _instances(tree: Tree) -> dict[Declaration, list[tuple[str, str, Mapping[str, object]]]]: + """Every declaration in the tree, with the ``(device id, topic, body)`` of each instance.""" + found: dict[Declaration, list[tuple[str, str, Mapping[str, object]]]] = {} + for device_id, topics in tree.items(): + description = _mapping(json.loads(topics["$description"])) + device_type = _text(description.get("type")) + for node_id, node in _mapping(description.get("nodes")).items(): + for property_id, definition in _mapping(_mapping(node).get("properties")).items(): + found.setdefault((device_type, node_id, property_id), []).append( + (device_id, f"{node_id}/{property_id}", _mapping(definition)) + ) + return found + + +def _perturbed(body: Mapping[str, object], current: str | None) -> str: + """A legal value for this property that differs from `current`. + + Legality matters: a value the parser rejects leaves the field unchanged and + the property reads as unconsumed. So the replacement is built from the same + declared `datatype` and `format` the mapper parses against. + """ + datatype = _text(body.get("datatype")) + if datatype in {"float", "integer"}: + try: + number = float(current or "") + except ValueError: + return "7" if datatype == "integer" else "7.5" + return str(int(number) + 7) if datatype == "integer" else str(number + 7.5) + if datatype == "boolean": + return "false" if (current or "").lower() == "true" else "true" + if datatype == "enum": + for option in _text(body.get("format")).split(","): + if option and option != current: + return option + return "probe-value" if current != "probe-value" else "probe-value-2" + + +@lru_cache(maxsize=1) +def _moved() -> Mapping[Declaration, frozenset[str]]: + """Republish each declared property once; return the snapshot fields it moved. + + One rebuild per declaring *device*, unioned: the two lugs devices and the + five circuits declare the same properties and are read differently, so a + single probe against whichever came first would answer for both. + """ + tree = _tree() + baseline = _snapshot_fields(_snapshot(tree)) + moved: dict[Declaration, frozenset[str]] = {} + for declaration, instances in _instances(tree).items(): + changed: set[str] = set() + for device_id, topic, body in instances: + current = tree[device_id].get(topic) + replacement = _perturbed(body, current) + assert replacement != current, ( + f"{declaration} on {device_id}: the probe equals the published value " + f"({current!r}), so this property is not being tested" + ) + mutated = {other: dict(topics) for other, topics in tree.items()} + mutated[device_id][topic] = replacement + after = _snapshot_fields(_snapshot(mutated)) + changed.update(path for path, value in after.items() if baseline.get(path) != value) + moved[declaration] = frozenset(changed) + return moved + + +def _declared() -> frozenset[Declaration]: + return frozenset(_instances(_tree())) + + +def _discovered() -> dict[str, DiscoveredMetadata]: + return build_discovery(devices_from_tree(parent_child_tree())) + + +def _rendered(declarations: Iterable[Declaration]) -> str: + return "\n".join(f" {'/'.join(item)}" for item in sorted(declarations)) or " (none)" + + +# --- the experiment must be able to observe anything at all ----------------- + + +def test_the_probe_moves_something_for_a_known_reading() -> None: + """The floor under every assertion below. + + All of them are satisfied by a probe that changes nothing, ever: the tables + would simply have to grow to match. This fails first if that happens. + """ + moved = _moved()[(TYPE_CIRCUIT, NODE_METER, "active-power")] + assert any(path.startswith("circuits@") and path.endswith(".instant_power_w") for path in moved), ( + f"republishing a circuit's active power moved {sorted(moved)}, which does not " + "include the reading it produces — the experiment is not observing the mapper" + ) + + +# --- the enumerations, checked against the mapper rather than against prose -- + + +def test_every_property_consumed_without_a_row_really_moves_the_snapshot() -> None: + """An entry that stops being true drops a property out of discovery silently. + + This is the direction with no natural signal: an over-broad "we read this" + table produces a *smaller* report, and a smaller report looks exactly like a + panel with nothing new on it. + """ + moved = _moved() + declared = _declared() + inert = [entry for entry in _CONSUMED_WITHOUT_A_ROW if entry in declared and not moved[entry]] + assert not inert, ( + "_CONSUMED_WITHOUT_A_ROW claims these are read into the snapshot and " + f"republishing them moves nothing:\n{_rendered(inert)}\n" + "Either the mapper stopped reading them — in which case they belong in " + "discovery — or the route is off-snapshot and belongs in " + "_CONSUMED_OFF_SNAPSHOT with the code that reads it named." + ) + + +def test_an_off_snapshot_route_that_became_observable_must_be_retired() -> None: + """The mirror of the integration's `test_no_internal_route_is_observable_after_all`. + + `_CONSUMED_OFF_SNAPSHOT` is the one table the experiment cannot verify + positively, so it is the one that could quietly become an allowlist. It is + held to the opposite claim instead: the moment a route's property does move + a snapshot field, the route is no longer the only thing consuming it and the + entry is hiding a real reader from whoever adds a property beside it. + """ + moved = _moved() + observable = [entry for entry in _CONSUMED_OFF_SNAPSHOT if moved.get(entry)] + assert not observable, ( + "off-snapshot route entries whose property now moves a snapshot field:\n" + f"{_rendered(observable)}\nDelete the entry; the mapper reads it now." + ) + + +def test_no_addressed_entry_has_gone_stale() -> None: + """A table entry outlives its declaration silently; the file only ever grows.""" + declared = _declared() + stale = [entry for entry in (*_CONSUMED_WITHOUT_A_ROW, *_CONSUMED_OFF_SNAPSHOT) if entry not in declared] + assert not stale, f"addressed-property entries the reference tree no longer declares:\n{_rendered(stale)}" + + +def test_no_addressed_entry_duplicates_a_metadata_row() -> None: + """The four tables partition the addressed set; they do not overlap. + + A property with a `_PROPERTY_FIELD_MAP` row already states its unit and + datatype for a snapshot field. Listing it again as read-without-a-row would + make the second entry unfalsifiable — deleting it changes nothing, so the + experiment above could never report it stale. + """ + with_rows = {(device_type, node_id, property_id) for device_type, node_id, property_id, _field in _PROPERTY_FIELD_MAP} + duplicated = [entry for entry in (*_CONSUMED_WITHOUT_A_ROW, *_CONSUMED_OFF_SNAPSHOT) if entry in with_rows] + assert not duplicated, f"addressed twice, once with a metadata row:\n{_rendered(duplicated)}" + + +def test_every_off_snapshot_route_names_the_code_that_reads_it() -> None: + """A reason-less entry is an allowlist line wearing an exemption's clothes.""" + thin = [entry for entry, reason in _CONSUMED_OFF_SNAPSHOT.items() if len(reason.split()) < 6] + assert not thin, f"off-snapshot entries with no usable reason:\n{_rendered(thin)}" + + +# --- what discovery reports, and that it is exactly right ------------------- + + +def test_discovery_reports_only_declarations_that_move_nothing() -> None: + """The claim a discovered row makes, asserted against the mapper. + + A row that moves a snapshot field is a false positive: something does read + it, and reporting it as unread sends a maintainer looking for a gap that is + not there. + """ + moved = _moved() + reported = set(_discovered()) + false_positives = [entry for entry in sorted(_declared()) if _path(entry) in reported and moved[entry]] + assert not false_positives, ( + "discovery reports these as unread and republishing them moves a snapshot " + f"field:\n{_rendered(false_positives)}\nAdd them to _CONSUMED_WITHOUT_A_ROW." + ) + + +def test_discovery_finds_every_declaration_nothing_reads() -> None: + """The converse, so an over-broad addressed table cannot shrink the report. + + Together with the test above this pins the output exactly: discovery is the + set of declarations that move no snapshot field, less the three routes that + are consumed where no snapshot field can show it. + """ + moved = _moved() + reported = set(_discovered()) + missing = [ + entry + for entry in sorted(_declared()) + if not moved[entry] and entry not in _CONSUMED_OFF_SNAPSHOT and _path(entry) not in reported + ] + assert not missing, ( + "these declarations move no snapshot field and discovery does not report " + f"them:\n{_rendered(missing)}\nAn addressed-property table claims a reader " + "that does not exist." + ) + + +def test_discovery_is_not_empty_on_the_reference_tree() -> None: + """A report that is always empty passes every assertion above. + + The reference capture is known to declare properties nothing reads — the + `connection/count` pair no producer publishes, the two deliberate skips in + `status`, the redundant `*-device-type` echoes. If this ever legitimately + reaches zero, the tests above are the ones that keep meaning something and + this is the one to delete, deliberately. + """ + assert len(_discovered()) >= 5 + + +def test_discovery_names_the_datatype_and_unit_the_tree_declares() -> None: + description = _mapping(json.loads(_tree()[PANEL_DEVICE_ID]["$description"])) + status = _mapping(_mapping(description.get("nodes")).get(NODE_STATUS)) + declared = _mapping(_mapping(status.get("properties")).get("postal-code")) + row = _discovered()["discovered.distribution-enclosure/status/postal-code"] + assert row.datatype == _text(declared.get("datatype")) + assert row.unit == (_text(declared.get("unit")) or None) + + +def test_retained_says_whether_a_value_has_arrived_and_never_what_it_is() -> None: + """`retained` is the declared-but-never-valued signal, and the only value question asked.""" + rows = _discovered() + assert rows["discovered.distribution-enclosure/status/time-zone"].retained is True + assert rows["discovered.circuit/connection/count"].retained is False + + tree = _tree() + del tree[PANEL_DEVICE_ID]["status/time-zone"] + unvalued = build_discovery(_devices(tree)) + assert unvalued["discovered.distribution-enclosure/status/time-zone"].retained is False + + +def _declaration_strings(tree: Tree) -> set[str]: + """Every string that appears anywhere in the capture's ``$description`` documents. + + Keys and values alike, plus the pieces of each — comma-separated `format` + options and dot-separated type stems — because a row names a device type by + its tail and a `format` option by itself. Published values are deliberately + not walked: this is the vocabulary a row is permitted to be built from. + """ + found: set[str] = set() + + def walk(node: object) -> None: + if isinstance(node, str): + found.add(node) + found.update(node.split(",")) + found.update(node.split(".")) + elif isinstance(node, Mapping): + for key, value in node.items(): + found.add(str(key)) + walk(value) + elif isinstance(node, list): + for item in node: + walk(item) + + for topics in tree.values(): + walk(json.loads(topics["$description"])) + return found + + +def test_no_published_value_reaches_a_discovery_row() -> None: + """The privacy constraint, asserted rather than reviewed. + + These rows are built to be forwarded in consumer diagnostics, which leave + the machine they were generated on, and the consumer's own redaction is + key-based and knows nothing about wire names — so nothing downstream can + protect a value put in here. + + Checked by provenance rather than by scanning for known strings, because a + scan is only as good as the capture's values happen to be distinctive. + Every string a row carries has to decompose into the vocabulary of the + `$description` documents, which hold no published values at all; the only + thing a row says about a value is the boolean `retained`. The scan runs too, + over the values that are *not* declaration vocabulary, as the empirical + half. + """ + tree = _tree() + allowed = _declaration_strings(tree) + rows = _discovered() + assert rows, "no rows, so this proves nothing" + + for path, row in rows.items(): + namespace, _, body = path.partition(".") + assert namespace == "discovered" + components = body.split("/") + assert len(components) == 3, f"{path} is not device-type/node/property" + for component in components: + assert component in allowed, f"{component!r} in {path} came from outside a declaration" + assert row.datatype in allowed + assert row.unit is None or row.unit in allowed + assert isinstance(row.retained, bool) + + published = { + value + for topics in tree.values() + for topic, value in topics.items() + if not topic.startswith("$") and value and value not in allowed + } + assert published, "every published value is also declaration vocabulary; the scan is vacuous" + emitted = "\n".join(f"{path} {row.datatype} {row.unit} {row.retained}" for path, row in rows.items()) + leaked = sorted(value for value in published if value in emitted) + assert not leaked, f"published values reached the discovery rows: {leaked}" + + +# --- the partition, and that it holds -------------------------------------- + + +def test_every_discovered_row_is_namespaced_and_no_curated_row_is() -> None: + """The partition a consumer applies, checked on the real metadata dict. + + `build_field_metadata` returns both kinds in one map, so the namespace is + the only thing standing between a discovered property and a consumer's + inventory of produced fields. + """ + metadata = build_field_metadata(devices_from_tree(parent_child_tree())) + discovered = set(_discovered()) + assert discovered, "no discovered rows, so the partition is untested" + assert discovered <= set(metadata) + + for path, row in metadata.items(): + if path in discovered: + assert is_discovery_path(path) + assert isinstance(row, DiscoveredMetadata) + else: + assert not is_discovery_path(path), f"{path} is curated and sits in the namespace" + assert not isinstance(row, DiscoveredMetadata) + + +def test_a_curated_field_path_is_never_a_discovery_path() -> None: + """No `_PROPERTY_FIELD_MAP` row can collide with the namespace.""" + for _device_type, _node, _prop, field_path in _PROPERTY_FIELD_MAP: + assert not is_discovery_path(field_path) + + +# --- it bites in both directions ------------------------------------------- + + +def test_a_property_nothing_reads_appears_with_its_declared_datatype_and_unit() -> None: + """Add a declaration to a copy of the tree; discovery must name it. + + The whole point of the runtime half: a panel in the field that starts + publishing a property is invisible to a vendored capture until somebody + recaptures, and this is the mechanism that makes it visible without one. + """ + tree = _tree() + description = _mapping(json.loads(tree[PANEL_DEVICE_ID]["$description"])) + nodes = dict(_mapping(description.get("nodes"))) + status = dict(_mapping(nodes.get(NODE_STATUS))) + status["properties"] = { + **_mapping(status.get("properties")), + "enclosure-temperature": { + "name": "Enclosure temperature", + "datatype": "float", + "unit": "°C", + }, + } + nodes[NODE_STATUS] = status + tree[PANEL_DEVICE_ID]["$description"] = json.dumps({**description, "nodes": nodes}) + tree[PANEL_DEVICE_ID]["status/enclosure-temperature"] = "41.5" + + row = build_discovery(_devices(tree))["discovered.distribution-enclosure/status/enclosure-temperature"] + assert row.datatype == "float" + assert row.unit == "°C" + assert row.retained is True + assert "41.5" not in repr(row) + + +def test_a_property_that_becomes_read_leaves_the_report(monkeypatch: pytest.MonkeyPatch) -> None: + """The other direction: once something addresses a property, it stops being reported. + + Mapping is what a maintainer does in response to a discovered row, so the + row disappearing is the acceptance criterion for that work. Patched rather + than edited so the test states the rule instead of tracking whichever + property happens to be unread this month. + """ + path = "discovered.distribution-enclosure/status/postal-code" + assert path in _discovered() + + monkeypatch.setattr( + field_metadata_module, + "_ADDRESSED", + _ADDRESSED | {(TYPE_PANEL, NODE_STATUS, "postal-code")}, + ) + assert path not in build_discovery(devices_from_tree(parent_child_tree())) + + +def test_a_subtyped_device_does_not_report_its_parents_mapped_properties() -> None: + """Subtyping is the false positive that would make the report unreadable. + + Firmware may declare `…device.lugs.upstream` rather than `…device.lugs` with + a direction property. Every mapped lugs property would then look + unaddressed — ten panel readings reported as newly discovered on a panel + where nothing changed. + """ + tree = _tree() + description = _mapping(json.loads(tree["lugs-upstream"]["$description"])) + tree["lugs-upstream"]["$description"] = json.dumps({**description, "type": f"{TYPE_LUGS}.upstream"}) + + rows = build_discovery(_devices(tree)) + assert not [path for path in rows if path.startswith("discovered.lugs.upstream/meter/")] + assert "discovered.lugs.upstream/connection/count" in rows + + +def test_the_charge_current_pair_is_addressed_by_resolution_not_by_a_table() -> None: + """A charger names its own charge-limit node, so discovery must resolve it too. + + `_charge_limit_metadata` produces rows for whichever spelling the charger + declares. A hardcoded pair here would report the other spelling as unread on + every charger that used it. + """ + rows = _discovered() + assert not [path for path in rows if path.endswith("max-charge-current")] + + +def test_a_device_mid_discovery_contributes_nothing() -> None: + """A device the tree names before it has described itself is normal, not a finding.""" + undescribed = DiscoveredDevice("not-yet-described", "ebus") + assert not build_discovery([undescribed]) From 4cc7572882a7c91d0be20fd0a130cdc855f6f926 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:31:05 -0700 Subject: [PATCH 092/115] feat(schema-1): report devices this adapter models nothing for TreeRoles sorts the tree into roles and anything matching none of them has always fallen off the end silently. The schema is vendor-extensible, so a device type nothing here models is an expected arrival rather than a hypothetical, and that silence is the only thing a consumer can render for it. Adoption is scoped to a device, never to a property. A new property on a modelled device is a curation task with a short turnaround, and surfacing it automatically spends a consumer's entity identity permanently on a shape a human would likely have chosen differently. Extra instances of a modelled type stay unadopted for the same reason: a second BESS is a multiplicity limit, not an unmodelled device. info and connection resolve to the device card and the device link rather than to readings, keyed on the node because the catalogs carry no marker for a device reference and a hard-coded name list goes stale silently. AdoptedProperty carries the value where DiscoveredMetadata must not: those rows are forwarded in diagnostics, which leave the machine. Separate types so conflating them is a type error. Additive: adopted_devices defaults empty, so schema_0 is untouched and the adapter contract does not move. --- CHANGELOG.md | 16 + .../src/span_panel_api_schema_1/adapter.py | 3 +- .../src/span_panel_api_schema_1/adoption.py | 151 +++++++++ .../span_panel_api_schema_1/description.py | 11 + .../span_panel_api_schema_1/field_metadata.py | 8 +- .../src/span_panel_api_schema_1/snapshot.py | 17 +- src/span_panel_api/__init__.py | 11 + src/span_panel_api/models.py | 161 ++++++++++ tests/test_adoption.py | 301 ++++++++++++++++++ tests/test_public_api_unchanged.py | 12 + 10 files changed, 677 insertions(+), 14 deletions(-) create mode 100644 packages/schema-1/src/span_panel_api_schema_1/adoption.py create mode 100644 tests/test_adoption.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 87d6548..1d35434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- **A device type this adapter models nothing for is reported whole rather than ignored: `SpanPanelSnapshot.adopted_devices`.** `TreeRoles` sorts the tree into the roles the snapshot needs, and anything that matches none of them has always fallen off the + end silently — a panel publishing a device nobody modelled produced no field, no metadata row and no sign it was there. The schema is explicitly vendor-extensible, so that is an expected arrival rather than a hypothetical one. `AdoptedDevice` carries the + device's identity and its readings; `span_panel_api_schema_1.adoption` builds one per unmodelled child. +- **The unit is a device, never a property, and that is the whole design.** A new property on a device this adapter already models is a curation task with a short turnaround, and surfacing it automatically spends a consumer's entity identity permanently on + a shape a human would likely have chosen differently — the sixteen `pcs` properties that curation collapsed into one entity and thirteen attributes are the worked example. An unmodelled _type_ is the opposite case: no curation is coming, so the silence + is the only alternative. Extra instances of a modelled type are deliberately not adopted either: a second BESS is a multiplicity limit, not an unmodelled device, and adopting it would stand a machine-named record beside a curated one for the same + hardware. +- **`info` and `connection` resolve away from readings, by node rather than by property name.** `info` is a device's build identity and becomes the card fields `AdoptedDevice` carries; `connection` is topology and becomes the device link. The partition is + keyed on the node because the catalogs carry no marker for "this string is a device reference", which leaves a hard-coded name list as the only alternative — and such a list goes stale silently: `ebus-sdk`'s own `topology.py` covers `feeds-device-id` and + `fed-by-device-id` and omits `grid-forming-entity`, which lives on the `grid` capability. A node is what the vocabulary defines, so keying on it cannot go stale the same way. +- **`AdoptedProperty` carries the value; `DiscoveredMetadata` still must not.** The two answer opposite questions and are separate types so that conflating them is a type error. Discovery rows are built to be forwarded in consumer diagnostics, which leave + the machine, so they carry declarations only. An adopted property exists to become an entity on the machine that built it, so it carries the reading — along with the declared `format` and `settable` flag, which are together the value domain a consumer + needs to build a control rather than a reading. +- **Additive, and deliberately not a protocol member.** `adopted_devices` defaults to `()`, so schema_0 — which has no device tree to find an unmodelled device in — is untouched, and `ADAPTER_CONTRACT_VERSION` does not move. `SchemaAdapter` derives its + required members from itself, so a member there would be required of every adapter package and would invalidate built wheels. + - **The capability catalogs are used as a validator, not just as a vocabulary list: `span_panel_api_schema_1.catalog`.** Sixteen catalogs have been vendored since v1.0 landed and were read only to assert that a catalog _exists_ for every node the adapter addresses. Nothing compared a declared `unit` or `datatype` against the catalog's definition of the same property, which is the comparison that catches a mislabel — and the one mislabel this repository has met (`meter/active-power` declared `kW` while the values are watts, a 1000x error) was found because a person noticed a sibling device declaring the same quantity differently. The new module compares one declaration against one catalog definition and classifies the result; diff --git a/packages/schema-1/src/span_panel_api_schema_1/adapter.py b/packages/schema-1/src/span_panel_api_schema_1/adapter.py index c13187a..985e2d3 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/adapter.py +++ b/packages/schema-1/src/span_panel_api_schema_1/adapter.py @@ -37,9 +37,10 @@ PROP_RELAY, STATE_READY, ) +from span_panel_api_schema_1.description import device_type from span_panel_api_schema_1.field_metadata import build_field_metadata from span_panel_api_schema_1.panel import integer -from span_panel_api_schema_1.snapshot import TreeRoles, build_snapshot, device_type, harmonised_evse_keys +from span_panel_api_schema_1.snapshot import TreeRoles, build_snapshot, harmonised_evse_keys from span_panel_api_schema_1.transport import ControllerRoutes if TYPE_CHECKING: diff --git a/packages/schema-1/src/span_panel_api_schema_1/adoption.py b/packages/schema-1/src/span_panel_api_schema_1/adoption.py new file mode 100644 index 0000000..6d00444 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/adoption.py @@ -0,0 +1,151 @@ +"""Build ``AdoptedDevice`` records for tree devices this adapter models no fields for. + +The unit of adoption is a **device**, never a property. A new property on a +device this adapter already models is a curation task with a short turnaround, +and minting something for it automatically spends a consumer's entity identity +permanently on a shape a human would likely have chosen differently. A device +type nothing here models is the opposite case: no curation is coming for it, so +surfacing what it publishes is strictly better than the silence that ships today. + +The schema is explicitly vendor-extensible, so an unmodelled type is an expected +arrival rather than a hypothetical one. + +**Values, unlike :mod:`field_metadata`'s discovery rows.** Those rows exist to be +forwarded in consumer diagnostics, which leave the machine, so they carry +declarations only. These records exist to become entities on the machine that +built them, so they carry the reading. The two must not be conflated, and the +types are separate so that conflating them is a type error rather than a leak. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from span_panel_api.models import ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE, AdoptedDevice, AdoptedProperty +from span_panel_api_schema_1.const import ( + TYPE_BESS, + TYPE_CIRCUIT, + TYPE_EVSE, + TYPE_INVERTER, + TYPE_LUGS, + TYPE_MID, + TYPE_PANEL, + TYPE_PV, +) +from span_panel_api_schema_1.description import device_type, nodes, optional_str, properties + +if TYPE_CHECKING: + from ebus_sdk.homie import DiscoveredDevice + +MODELLED_TYPES: tuple[str, ...] = ( + TYPE_PANEL, + TYPE_CIRCUIT, + TYPE_LUGS, + TYPE_EVSE, + TYPE_BESS, + TYPE_PV, + TYPE_MID, + TYPE_INVERTER, +) +"""Every device type this adapter builds snapshot fields from. + +Stated once here and asserted against the snapshot builder by test, rather than +derived from it: `TreeRoles` sorts by a chain of comparisons that no expression +can read back, and a type silently dropping out of that chain while staying in +this tuple would make a device invisible to *both* paths -- unmodelled by the +builder and unadopted by this module. The test is what closes that. +""" + +PROP_VENDOR_NAME = "vendor-name" +PROP_MODEL = "model" +PROP_SERIAL_NUMBER = "serial-number" +PROP_FIRMWARE_VERSION = "firmware-version" +PROP_HARDWARE_VERSION = "hardware-version" + + +def is_modelled(declared: str) -> bool: + """Whether this adapter builds snapshot fields from a device of this type. + + Subtype-aware, because firmware may declare either a base type or a subtype + of it -- ``…device.lugs`` with a ``direction`` property, or + ``…device.lugs.upstream``. A subtype of something modelled is modelled: the + snapshot builder matches lugs by prefix for exactly this reason, and a + subtype arriving must not be adopted behind the builder's back. + """ + return any(declared == known or declared.startswith(f"{known}.") for known in MODELLED_TYPES) + + +def build_adopted_devices(children: list[DiscoveredDevice]) -> tuple[AdoptedDevice, ...]: + """Adopt every child whose declared type this adapter models nothing for. + + A device mid-discovery declares no type at all, which is a normal state + rather than an unmodelled device: it is skipped rather than adopted, and + adopted on a later snapshot once its description arrives. + + Extra instances of a modelled type are deliberately *not* adopted. A second + BESS is a multiplicity limitation of the snapshot model, not an unmodelled + device, and adopting it would stand a machine-named device card beside a + curated one describing the same class of hardware. + """ + adopted: list[AdoptedDevice] = [] + for device in children: + declared = device_type(device) + if not declared or is_modelled(declared): + continue + adopted.append(_adopt(device, declared)) + return tuple(adopted) + + +def _adopt(device: DiscoveredDevice, declared: str) -> AdoptedDevice: + """One device's identity, from ``info``, and its readings, from everything else.""" + description: dict[str, object] = device.description or {} + declared_nodes = nodes(description) + identity = properties(declared_nodes.get(ADOPTION_IDENTITY_NODE, {})) + + def card(property_id: str) -> str | None: + """An ``info`` property's value, for the device card rather than an entity.""" + if property_id not in identity: + return None + return optional_str(device.get_property(ADOPTION_IDENTITY_NODE, property_id)) + + return AdoptedDevice( + device_id=device.device_id, + device_type=declared, + name=optional_str(description.get("name")), + vendor_name=card(PROP_VENDOR_NAME), + model=card(PROP_MODEL), + serial_number=card(PROP_SERIAL_NUMBER), + software_version=card(PROP_FIRMWARE_VERSION), + hardware_version=card(PROP_HARDWARE_VERSION), + properties=_readings(device, declared_nodes), + ) + + +def _readings(device: DiscoveredDevice, declared_nodes: dict[str, dict[str, object]]) -> tuple[AdoptedProperty, ...]: + """Every declared property outside the identity and topology nodes. + + Those two are excluded by *node*, which is what the eBus vocabulary defines, + rather than by property name. The catalogs carry no marker for "this string + is a device reference", so a name list is the only alternative -- and a name + list goes stale silently, as `ebus-sdk`'s own ``topology.py`` demonstrates by + covering two device-reference properties and omitting a third that lives on + a different capability. + """ + readings: list[AdoptedProperty] = [] + for node_id, node in declared_nodes.items(): + if node_id in (ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE): + continue + for property_id, definition in properties(node).items(): + raw = device.get_property(node_id, property_id) + readings.append( + AdoptedProperty( + node_id=node_id, + property_id=property_id, + datatype=str(definition.get("datatype") or "string"), + unit=optional_str(definition.get("unit")), + format=optional_str(definition.get("format")), + settable=bool(definition.get("settable", False)), + value=None if raw is None else str(raw), + ) + ) + return tuple(readings) diff --git a/packages/schema-1/src/span_panel_api_schema_1/description.py b/packages/schema-1/src/span_panel_api_schema_1/description.py index 1493d0a..ac5e01d 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/description.py +++ b/packages/schema-1/src/span_panel_api_schema_1/description.py @@ -18,6 +18,17 @@ from ebus_sdk.homie import DiscoveredDevice +def device_type(device: DiscoveredDevice) -> str: + """The device's declared type from its description, or '' before it arrives. + + A device exists in the tree from the moment its parent names it as a child, + so an empty type is the normal mid-discovery state rather than an error. + """ + description: dict[str, object] = device.description or {} + declared = description.get("type") + return str(declared) if declared else "" + + def nodes(description: dict[str, object]) -> dict[str, dict[str, object]]: """The capability nodes a description declares, by node id.""" declared = description.get("nodes") diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index 7f0e42a..646e004 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -52,9 +52,13 @@ TYPE_PANEL, TYPE_PV, ) -from span_panel_api_schema_1.description import nodes as declared_nodes, optional_str, properties as declared_properties +from span_panel_api_schema_1.description import ( + device_type as declared_type, + nodes as declared_nodes, + optional_str, + properties as declared_properties, +) from span_panel_api_schema_1.panel import PROP_CURRENT_A, PROP_CURRENT_B, find_lugs -from span_panel_api_schema_1.snapshot import device_type as declared_type if TYPE_CHECKING: from ebus_sdk.homie import DiscoveredDevice diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index 42f2726..4d6b83b 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING from span_panel_api.models import SpanPanelSnapshot +from span_panel_api_schema_1.adoption import build_adopted_devices from span_panel_api_schema_1.circuits import build_circuit from span_panel_api_schema_1.const import ( NODE_INFO, @@ -25,6 +26,7 @@ TYPE_MID, TYPE_PV, ) +from span_panel_api_schema_1.description import device_type from span_panel_api_schema_1.devices import ( build_battery, build_evse, @@ -53,17 +55,6 @@ from ebus_sdk.homie import DiscoveredDevice -def device_type(device: DiscoveredDevice) -> str: - """The device's declared type from its description, or '' before it arrives. - - A device exists in the tree from the moment its parent names it as a child, - so an empty type is the normal mid-discovery state rather than an error. - """ - description: dict[str, object] = device.description or {} - declared = description.get("type") - return str(declared) if declared else "" - - class TreeRoles: """The tree sorted into the roles a snapshot needs. @@ -208,6 +199,10 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re # capability publishes is legally `0.0`, so there is no reading that can # distinguish a switched-off PCS from an absent one. See `build_pcs`. pcs=build_pcs(panel), + # Every child whose type nothing above sorts into a role. Built from the + # same `children` the roles were sorted from, so a type dropping out of + # `TreeRoles` surfaces here rather than vanishing from both. + adopted_devices=build_adopted_devices(children), evse={ key: build_evse(device, feeds, node_id=key, feed_statuses=feed_statuses) for device, key in harmonised_evse_keys(roles.evse).items() diff --git a/src/span_panel_api/__init__.py b/src/span_panel_api/__init__.py index 872542e..391f629 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -31,7 +31,11 @@ ) from .factory import create_span_client from .models import ( + ADOPTION_IDENTITY_NODE, + ADOPTION_TOPOLOGY_NODE, DISCOVERY_NAMESPACE, + AdoptedDevice, + AdoptedProperty, DiscoveredMetadata, FieldMetadata, HomieSchemaTypes, @@ -90,6 +94,13 @@ "DISCOVERY_NAMESPACE", "DiscoveredMetadata", "is_discovery_path", + # Added 2026-08-20: device-scoped adoption. Additive in the same way -- + # `SpanPanelSnapshot.adopted_devices` defaults empty, so an adapter that + # adopts nothing and a consumer that reads the field are both unaffected. + "ADOPTION_IDENTITY_NODE", + "ADOPTION_TOPOLOGY_NODE", + "AdoptedDevice", + "AdoptedProperty", # Snapshots "SpanBatterySnapshot", "SpanCircuitSnapshot", diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 933ff63..7283452 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -570,6 +570,153 @@ def panel_size(self) -> int: raise ValueError(f"Cannot parse max from space format '{fmt}'") from exc +ADOPTION_IDENTITY_NODE = "info" +"""The node whose properties are a device's build identity, never entities. + +`info/model`, `info/serial-number`, `info/firmware-version` and their siblings +describe the thing rather than report a reading. On a curated device they already +land on the device card -- `bess_device_info` has read them that way since v1.0 -- +and an adopted device gets the same treatment for the same reason. +""" + +ADOPTION_TOPOLOGY_NODE = "connection" +"""The node that says what a device hangs off, never entities. + +`connection` answers a device-tree question: which device feeds this one, which +one it feeds, and the health of that link. That is `via_device` and the registry, +not a sensor -- a panel publishing its own wiring should not arrive as a handful +of entities holding opaque device ids. + +The partition is by node rather than by property name deliberately. The eBus +catalogs carry no marker for "this string is a device reference", so a consumer +that wants one has to hard-code the property names, and that list goes stale: +`ebus-sdk`'s own `topology.py` covers `feeds-device-id` and `fed-by-device-id` +and silently omits `grid-forming-entity`, which lives on the `grid` capability. +A node is what the vocabulary defines, so keying on it cannot go stale that way. +""" + + +@dataclass(frozen=True, slots=True) +class AdoptedProperty: + """One property of a device this library models no snapshot field for. + + The counterpart to `DiscoveredMetadata`, and deliberately not the same type. + A discovered row describes a property on a device the adapter *does* model + and exists to be forwarded in diagnostics, so it carries no value by + construction. An adopted property belongs to a device nothing here models at + all, and its whole purpose is to reach a consumer as a reading -- so it + carries the value, and must never be put in diagnostics. + """ + + node_id: str + """The Homie node, e.g. `meter`. + + Never `info` or `connection`: those two resolve to the device card and the + device tree before this type is built. + """ + + property_id: str + """The Homie property, e.g. `active-power`.""" + + datatype: str + """The declared Homie datatype -- `float`, `integer`, `boolean`, `enum`, `string`. + + What a consumer parses the value with, and half of what it picks a platform + with. + """ + + unit: str | None = None + """The declared unit, verbatim. + + `None` when the declaration carries none, which is the normal case for a + `boolean` or an `enum`. + """ + + format: str | None = None + """The declared Homie `$format`: an option list for an `enum`, a + `min:max:step` range for a number. + + Load-bearing for a settable property, because it is the value domain. A + select with no option list and a number with no bounds are not controls a + consumer can build, so its absence is what makes a settable property surface + read-only rather than as a control. + """ + + settable: bool = False + """Whether the panel accepts a write to this property.""" + + value: str | None = None + """The retained value as published, unparsed. + + `None` when the property is declared and nothing has arrived. + """ + + @property + def path(self) -> str: + """`{node}/{property}` -- how the capability catalogs spell it.""" + return f"{self.node_id}/{self.property_id}" + + +@dataclass(frozen=True, slots=True) +class AdoptedDevice: + """A device on the tree whose type this library models no fields for. + + Adoption is scoped to a whole device rather than to a property, and the + distinction is the design. A new property on a device we *do* model is a + curation task with a short turnaround, and minting an entity for it spends an + entity id permanently on a shape a human would likely have chosen differently + -- the sixteen `pcs` properties that curation collapsed into one entity and + thirteen attributes are the worked example. A device type nothing here models + is the opposite case: no curation is coming, so surfacing it is strictly + better than the silence that ships today. + + Extra instances of a *modelled* type are deliberately not adopted. A second + BESS is a multiplicity limitation, not an unmodelled device, and adopting it + would put a machine-named device card beside a curated one describing the + same class of hardware. + """ + + device_id: str + """The device's own id on the wire. + + Opaque, and per the eBus proxy rule (`{proxier-id}-{proxied-id}`) not + comparable across enclosures -- the same physical device carries different + ids under different proxiers by design. Usable as this panel's local handle, + never as a cross-panel identity. + """ + + device_type: str + """The declared `$type`, e.g. `energy.ebus.device.generator`, verbatim.""" + + name: str | None = None + """The device's declared Homie `name`, when it publishes one.""" + + vendor_name: str | None = None + """`info/vendor-name` -- for the device card.""" + + model: str | None = None + """`info/model` -- for the device card.""" + + serial_number: str | None = None + """`info/serial-number` -- for the device card. + + Deliberately *not* an identity-anchor decision made here. A consumer that + keys a device registry on an anchor must freeze it at first sighting: a + serial arriving on a device already adopted under its wire id is new + information for the card and nothing else, because re-deriving the anchor + turns an upgrade into a device replacement and takes the entities with it. + """ + + software_version: str | None = None + """`info/firmware-version` -- for the device card.""" + + hardware_version: str | None = None + """`info/hardware-version` -- for the device card.""" + + properties: tuple[AdoptedProperty, ...] = () + """Everything outside `info` and `connection`, in declaration order.""" + + @dataclass(frozen=True, slots=True) class SpanPanelSnapshot: """Complete panel state — single point-in-time view.""" @@ -718,6 +865,20 @@ class SpanPanelSnapshot: signal. A new optional device should not inherit that: presence is `snapshot.mid is not None`, with nothing to infer. """ + adopted_devices: tuple[AdoptedDevice, ...] = () + """Devices on the tree whose type this library models no fields for. + + Empty for every adapter that does not answer the question. schema_0 never + populates it: flat has no device tree to find an unmodelled device in, and + panels upgrade to v1.0 and stay there, so adoption operates in the schema + that is the terminus. + + A defaulted snapshot field rather than a `SchemaAdapter` member, on purpose. + The protocol derives its required members from itself, so a new member is + required of every adapter package and invalidates built wheels; a snapshot + field that defaults empty is additive and costs neither. + """ + pcs: SpanPcsSnapshot | None = None """The enclosure's Power Control System, when it publishes a `pcs` node. v1.0 only. diff --git a/tests/test_adoption.py b/tests/test_adoption.py new file mode 100644 index 0000000..0fc2b98 --- /dev/null +++ b/tests/test_adoption.py @@ -0,0 +1,301 @@ +"""Devices this adapter models nothing for are adopted whole; modelled ones never are. + +The rule under test has two halves and both are failure modes. Adopting a device +the snapshot builder already reads would stand a machine-named device card beside +a curated one describing the same hardware. *Not* adopting one the builder +ignores is the silence this module exists to end -- a panel publishing a device +nobody modelled, and no sign of it anywhere. + +Every case is built by putting a device on the reference tree and reading what +comes back, never by calling the classifier directly: the question is what a +panel gets, and a classifier that agrees with itself proves nothing about that. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest +from span_panel_api.models import SpanPanelSnapshot +from span_panel_api_schema_1.adoption import MODELLED_TYPES +from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree +from span_panel_api_schema_1.snapshot import build_snapshot + +if TYPE_CHECKING: + from span_panel_api.models import AdoptedDevice + +PANEL = "example-40t-001" + +UNMODELLED_TYPE = "energy.ebus.device.generator" +"""A type this adapter models nothing for. + +A generator rather than an invented string: the eBus vocabulary already names one +as a grid-forming device class, and the schema is explicitly vendor-extensible, +so an unmodelled arrival is the expected case rather than a hypothetical. +""" + + +def _tree() -> dict[str, dict[str, str]]: + return {device_id: dict(topics) for device_id, topics in parent_child_tree().items()} + + +def _snapshot(tree: dict[str, dict[str, str]]) -> SpanPanelSnapshot: + panel = device_from_topics(PANEL, tree[PANEL]) + children = [device_from_topics(device_id, topics) for device_id, topics in tree.items() if device_id != PANEL] + return build_snapshot(panel, children) + + +def _device( + device_type: str, + *, + name: str = "Backup Generator", + nodes: dict[str, dict[str, dict[str, object]]] | None = None, + values: dict[str, str] | None = None, +) -> dict[str, str]: + """One device's retained topics, as the broker hands them back. + + `$description` is a JSON *string* rather than a nested object, which is how + the transport carries it and how the reference payload stores it. + """ + description: dict[str, object] = { + "homie": "5.0", + "version": 1, + "type": device_type, + "name": name, + "nodes": nodes or {}, + } + topics = {"$description": json.dumps(description), "$state": "ready"} + topics.update(values or {}) + return topics + + +def _with(tree: dict[str, dict[str, str]], device_id: str, topics: dict[str, str]) -> dict[str, dict[str, str]]: + tree[device_id] = topics + return tree + + +def _adopted(snapshot: SpanPanelSnapshot) -> dict[str, AdoptedDevice]: + return {device.device_id: device for device in snapshot.adopted_devices} + + +# -- The reference tree adopts nothing --------------------------------------- + + +def test_a_tree_of_modelled_devices_adopts_nothing() -> None: + """The captured tree is thirteen devices this adapter reads, so it adopts none. + + The baseline the rest of this module measures against: anything that shows up + here is a modelled device leaking into adoption, which is the failure that + duplicates a curated device card. + """ + assert _snapshot(_tree()).adopted_devices == () + + +@pytest.mark.parametrize("modelled", MODELLED_TYPES) +def test_no_modelled_type_is_ever_adopted(modelled: str) -> None: + """Every type the snapshot builder sorts into a role stays out of adoption. + + Parametrised over the declared tuple and asserted through `build_snapshot`, + so the tuple cannot drift from the builder silently: a type dropped from + `TreeRoles` while left in `MODELLED_TYPES` would make its devices invisible + to both paths at once, and this is what fails instead. + """ + tree = _with(_tree(), "extra-device", _device(modelled)) + assert "extra-device" not in _adopted(_snapshot(tree)) + + +@pytest.mark.parametrize("subtype", [f"{TYPE}.upstream" for TYPE in MODELLED_TYPES]) +def test_a_subtype_of_a_modelled_type_is_not_adopted(subtype: str) -> None: + """Firmware may subtype a device class, and the builder matches lugs by prefix. + + A subtype adopted behind the builder's back is the same duplicate-device + failure, arriving through a spelling rather than through a type. + """ + tree = _with(_tree(), "extra-device", _device(subtype)) + assert "extra-device" not in _adopted(_snapshot(tree)) + + +def test_a_device_declaring_no_type_yet_is_skipped_rather_than_adopted() -> None: + """A device describing itself without a type yet is not an unmodelled device. + + Mid-discovery is a normal state rather than an error -- `device_type` answers + `""` for it by design. Adopting on that would mint a device card for + something whose type is about to arrive, and then leave it standing when the + real type turns out to be one this adapter models. + """ + untyped = json.dumps({"homie": "5.0", "version": 1, "name": "Arriving", "nodes": {}}) + tree = _with(_tree(), "still-arriving", {"$description": untyped, "$state": "init"}) + assert _snapshot(tree).adopted_devices == () + + +# -- An unmodelled type is adopted whole ------------------------------------- + + +def test_an_unmodelled_type_is_adopted() -> None: + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE)) + adopted = _adopted(_snapshot(tree)) + + assert set(adopted) == {"generator-1"} + assert adopted["generator-1"].device_type == UNMODELLED_TYPE + assert adopted["generator-1"].name == "Backup Generator" + + +def test_adoption_carries_the_value_where_discovery_carries_only_the_declaration() -> None: + """The one difference between the two records, and the reason they are two types. + + A discovery row is built to be forwarded in diagnostics, which leave the + machine, so it has no member a reading can go in. An adopted property is + built to become an entity on the machine that made it, so it must carry one. + """ + nodes = {"meter": {"properties": {"active-power": {"datatype": "float", "unit": "W"}}}} + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE, nodes=nodes, values={"meter/active-power": "2400"})) + + (reading,) = _adopted(_snapshot(tree))["generator-1"].properties + assert (reading.node_id, reading.property_id) == ("meter", "active-power") + assert (reading.datatype, reading.unit) == ("float", "W") + assert reading.value == "2400" + assert reading.path == "meter/active-power" + + +def test_a_declared_property_with_nothing_published_adopts_with_no_value() -> None: + """Declared-and-never-valued is a state to report, not a property to drop. + + Dropping it would make the entity appear only once the panel first published, + which reads to a user as an entity that comes and goes. + """ + nodes = {"meter": {"properties": {"active-power": {"datatype": "float", "unit": "W"}}}} + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE, nodes=nodes)) + + (reading,) = _adopted(_snapshot(tree))["generator-1"].properties + assert reading.value is None + + +def test_the_declared_format_and_settable_flag_survive_adoption() -> None: + """Both halves of what a consumer needs to build a control rather than a reading. + + `settable` says a write is accepted; `format` is the value domain that makes + the control constructible. A select with no option list is not a safer + control, it is a broken one, so the consumer needs to see both. + """ + nodes = { + "generator": { + "properties": { + "mode": { + "datatype": "enum", + "format": "AUTO,MANUAL,OFF", + "settable": True, + } + } + } + } + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE, nodes=nodes)) + + (control,) = _adopted(_snapshot(tree))["generator-1"].properties + assert control.settable is True + assert control.format == "AUTO,MANUAL,OFF" + assert control.unit is None + + +# -- info and connection resolve away from entities -------------------------- + + +def test_info_becomes_the_device_card_and_not_properties() -> None: + """`info` describes the thing rather than reporting a reading. + + The same treatment `bess_device_info` has given a curated device since v1.0, + applied to an adopted one for the same reason. + """ + nodes = { + "info": { + "properties": { + "vendor-name": {"datatype": "string"}, + "model": {"datatype": "string"}, + "serial-number": {"datatype": "string"}, + "firmware-version": {"datatype": "string"}, + "hardware-version": {"datatype": "string"}, + } + } + } + values = { + "info/vendor-name": "Example Power", + "info/model": "GEN-9000", + "info/serial-number": "EX-0000-0001", + "info/firmware-version": "3.2.1", + "info/hardware-version": "rev-C", + } + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE, nodes=nodes, values=values)) + + device = _adopted(_snapshot(tree))["generator-1"] + assert device.properties == () + assert device.vendor_name == "Example Power" + assert device.model == "GEN-9000" + assert device.serial_number == "EX-0000-0001" + assert device.software_version == "3.2.1" + assert device.hardware_version == "rev-C" + + +def test_connection_is_dropped_rather_than_surfaced() -> None: + """`connection` is the device tree, which is `via_device`, not a sensor. + + Excluded by node rather than by property name on purpose. The catalogs carry + no marker for "this string is a device reference", so a name list is the only + alternative -- and a name list goes stale silently, which is what `ebus-sdk`'s + own `topology.py` does by covering two such properties and omitting a third. + """ + nodes = { + "connection": { + "properties": { + "fed-by-device-id": {"datatype": "string"}, + "feeds-device-type": {"datatype": "string"}, + } + }, + "meter": {"properties": {"active-power": {"datatype": "float", "unit": "W"}}}, + } + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE, nodes=nodes)) + + device = _adopted(_snapshot(tree))["generator-1"] + assert [reading.path for reading in device.properties] == ["meter/active-power"] + + +def test_an_info_property_the_card_has_no_field_for_is_not_promoted_to_an_entity() -> None: + """`info` is excluded by node, so an unrecognised member of it is dropped too. + + The alternative -- dropping only the five the card reads -- would surface + `info/nominal-power` and its siblings as string sensors the moment a vendor + declared one, which is the metadata-as-entities failure the node rule exists + to prevent. + """ + nodes = {"info": {"properties": {"nominal-power": {"datatype": "float", "unit": "W"}}}} + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE, nodes=nodes, values={"info/nominal-power": "9000"})) + + assert _adopted(_snapshot(tree))["generator-1"].properties == () + + +# -- Multiplicity is not adoption -------------------------------------------- + + +def test_a_second_bess_is_not_adopted() -> None: + """A modelled type arriving twice is a multiplicity limit, not an unmodelled device. + + `TreeRoles` keeps the first BESS and silently ignores the rest, which is a + real gap -- but adopting the extra one would answer it with a machine-named + device card standing beside the curated Battery, describing the same + hardware. The gap stays visible as a gap instead. + """ + tree = _with(_tree(), "bess-2", _device("energy.ebus.device.bess", name="Second Battery")) + assert _snapshot(tree).adopted_devices == () + + +# -- schema_0 adopts nothing ------------------------------------------------- + + +def test_the_snapshot_field_defaults_empty() -> None: + """What makes the field additive rather than a protocol change. + + schema_0 never populates it: flat has no device tree to find an unmodelled + device in. A default of `()` is what lets that adapter stay untouched and + keeps `adopted_devices` off `SchemaAdapter`, whose members are required of + every adapter package. + """ + assert SpanPanelSnapshot.__dataclass_fields__["adopted_devices"].default == () diff --git a/tests/test_public_api_unchanged.py b/tests/test_public_api_unchanged.py index e8c2c8c..f9dd5b3 100644 --- a/tests/test_public_api_unchanged.py +++ b/tests/test_public_api_unchanged.py @@ -39,6 +39,18 @@ # curated rows it saw before. "DISCOVERY_NAMESPACE", "DiscoveredMetadata", + # Added 2026-08-20: device-scoped adoption -- the two nodes whose properties + # resolve to a device card and a device link rather than to entities, and the + # pair of records an adapter reports an unmodelled device with. Additive for + # the same reason: `SpanPanelSnapshot.adopted_devices` defaults empty, so an + # adapter that adopts nothing and a consumer that never reads the field are + # both unaffected. Deliberately not `SchemaAdapter` members -- the protocol + # derives its required set from itself, so a member there would be required + # of every adapter package and would invalidate built wheels. + "ADOPTION_IDENTITY_NODE", + "ADOPTION_TOPOLOGY_NODE", + "AdoptedDevice", + "AdoptedProperty", "is_discovery_path", # Snapshots "SpanBatterySnapshot", From 17f567c4a1dc90915c1b5a8a8628aa87e72dd24a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:45:52 -0700 Subject: [PATCH 093/115] docs(development): explain device-scoped adoption and what it deliberately excludes --- DEVELOPMENT.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b764648..4ffdfb1 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -127,6 +127,49 @@ report, and a smaller report looks exactly like a panel with nothing new on it. a snapshot field, and every reported property moves none. `_CONSUMED_OFF_SNAPSHOT` holds the three declarations consumed by a route no snapshot field can show (tier-1 dispatch, the shadowed islanding tier, the unreached feedthrough branch); each names the code that reads it, and each fails the day its property does move a field. +## Devices this library models nothing for + +`TreeRoles` sorts a v1.0 tree into the roles a snapshot needs. Anything matching none of them used to fall off the end silently — a panel publishing a device type nobody modelled produced no field, no metadata row and no sign it was there. The eBus schema +is explicitly vendor-extensible, so that is an expected arrival rather than a hypothetical. + +`span_panel_api_schema_1.adoption` builds an `AdoptedDevice` for each such child, and `build_snapshot` puts them on `SpanPanelSnapshot.adopted_devices`. + +### The two rules that keep it from being a firehose + +**The unit is a device, never a property.** A new property on a device this adapter already models is a curation task with a short turnaround, and surfacing it automatically would spend a consumer's entity identity permanently on a shape a human would +likely have chosen differently. An unmodelled _type_ is the opposite case: no curation is coming, so the alternative is silence. + +**Extra instances of a modelled type are not adopted.** `TreeRoles` keeps the first BESS and ignores the rest, which is a real gap — but adopting the extra one would stand a machine-named record beside a curated one describing the same hardware. The gap +stays visible as a gap. + +`MODELLED_TYPES` states the modelled set once, and `tests/test_adoption.py` parametrises over it through `build_snapshot` rather than through the classifier. That is what stops the tuple drifting from the builder: a type dropped from `TreeRoles` while left +in the tuple would make its devices invisible to both paths at once. + +### `info` and `connection` resolve away from readings + +`ADOPTION_IDENTITY_NODE` (`info`) becomes the device's card fields; `ADOPTION_TOPOLOGY_NODE` (`connection`) is dropped, because it is a device-tree question rather than a reading. + +Keyed on the **node**, not on property names. The catalogs carry no marker for "this string is a device reference", so a name list is the only alternative — and it goes stale silently: `ebus-sdk`'s own `topology.py` covers `feeds-device-id` and +`fed-by-device-id` and omits `grid-forming-entity`, which lives on the `grid` capability. A node is what the vocabulary defines. + +### `AdoptedProperty` carries the value; `DiscoveredMetadata` must not + +The two answer opposite questions and are separate types so that conflating them is a type error rather than a leak: + +| Type | Question | Destination | Carries a value | +| -------------------- | -------------------------------------------- | --------------------------------- | --------------- | +| `DiscoveredMetadata` | "we model this device and read nothing here" | consumer diagnostics, which leave | **no** | +| `AdoptedProperty` | "nothing here models this device at all" | an entity on the same machine | **yes** | + +`AdoptedProperty` also carries the declared `format` and `settable` flag, which together are the value domain a consumer needs to build a control rather than a reading. + +### Additive by construction + +`adopted_devices` defaults to `()`. schema_0 never populates it — flat has no device tree to find an unmodelled device in, and panels upgrade to v1.0 and stay there, so adoption operates in the schema that is the terminus. + +A defaulted snapshot field rather than a `SchemaAdapter` member, deliberately: the protocol derives its required members from itself, so a member there would be required of every adapter package and would invalidate built adapter wheels. +`ADAPTER_CONTRACT_VERSION` does not move. + ## Linting and Formatting Pre-commit hooks run automatically on commit. To run all hooks manually: From 48a9832302ded8cfa5639e965a455ada8e952ffb Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:33:06 -0700 Subject: [PATCH 094/115] feat: write to a settable property of an adopted device AdoptedProperty.set_topic is populated only for a settable property on a device is_modelled rejects, so the scoping is the authorisation rather than a check a caller has to remember. set_adopted_property resolves the property against the current snapshot and publishes to the topic it carries, accepting no topic from its caller. The alternative was a set_property_topic member on SchemaAdapter, rejected twice over. It would put every curated control one argument away, and two of them do real work on the way out: dominant_power_source_payload translates GRID into ON_GRID, and evse_charge_limit_payload refuses a value above the commissioned ceiling. And _derive_required_members would make it required of every adapter package, so an older adapter wheel would fail at discovery rather than losing one feature. No translation and no bounds check on an adopted write: the declaration is all this library knows, and inventing a bound would invent a fact about somebody else's hardware. Versions bumped across all three packages because the in-tree b5 was never published and would otherwise mean two different things. --- CHANGELOG.md | 11 ++ DEVELOPMENT.md | 13 ++ packages/schema-0/pyproject.toml | 2 +- packages/schema-1/pyproject.toml | 2 +- .../src/span_panel_api_schema_1/adoption.py | 19 ++- pyproject.toml | 2 +- src/span_panel_api/__init__.py | 6 + src/span_panel_api/models.py | 19 +++ src/span_panel_api/mqtt/client.py | 52 +++++++- src/span_panel_api/protocol.py | 20 +++ tests/test_adopted_control.py | 120 ++++++++++++++++++ tests/test_adoption.py | 54 ++++++++ tests/test_public_api_unchanged.py | 1 + uv.lock | 6 +- 14 files changed, 319 insertions(+), 8 deletions(-) create mode 100644 tests/test_adopted_control.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d35434..8675cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- **A settable property on an adopted device can be written, and the write cannot reach anything else: `AdoptedProperty.set_topic` and `SpanMqttClient.set_adopted_property`.** The topic is populated only for a settable property on a device `is_modelled` + rejects, so it is the scoping that authorises the write rather than a check a caller has to remember. The transport resolves the property against the current snapshot's `adopted_devices` and publishes to the topic that property carries; no topic is + accepted from the caller, and a device this library models produces no `AdoptedDevice` to find. +- **The alternative was a `set_property_topic` member on `SchemaAdapter`, and it was rejected for two independent reasons.** It would have put every curated control one argument away, and two of them do real work on the way out — + `dominant_power_source_payload` translates `GRID` into the `ON_GRID` the v1.0 islanding assertion accepts, and `evse_charge_limit_payload` refuses a value above the commissioned ceiling because publishing past it is the one write with a physical + consequence. It would also have been required of every adapter package, since `_derive_required_members` derives the required set from the protocol, so an installation carrying an older adapter wheel would have failed at _discovery_ rather than losing + one feature. +- **No translation and no bounds check on an adopted write, deliberately.** Both exist on curated controls because this library knows what those properties mean. It knows nothing about an adopted one beyond its declaration, and inventing a bound would be + inventing a fact about somebody else's hardware. The consumer constrains the value to the declared `format`; the panel stays the authority on whether to accept it. +- **`AdoptedControlProtocol`**, so a consumer asks `isinstance` before offering the control, exactly as it does for circuit, panel and EVSE control. + - **A device type this adapter models nothing for is reported whole rather than ignored: `SpanPanelSnapshot.adopted_devices`.** `TreeRoles` sorts the tree into the roles the snapshot needs, and anything that matches none of them has always fallen off the end silently — a panel publishing a device nobody modelled produced no field, no metadata row and no sign it was there. The schema is explicitly vendor-extensible, so that is an expected arrival rather than a hypothetical one. `AdoptedDevice` carries the device's identity and its readings; `span_panel_api_schema_1.adoption` builds one per unmodelled child. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4ffdfb1..d7d1a27 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -163,6 +163,19 @@ The two answer opposite questions and are separate types so that conflating them `AdoptedProperty` also carries the declared `format` and `settable` flag, which together are the value domain a consumer needs to build a control rather than a reading. +### Writing to an adopted property + +`AdoptedProperty.set_topic` is populated **only** for a settable property on a device `is_modelled` rejects. That scoping is the authorisation rather than a check somebody has to remember: `SpanMqttClient.set_adopted_property` resolves the property against +the current snapshot's `adopted_devices` and publishes to the topic that property carries, and accepts no topic from its caller. + +The alternative — a `set_property_topic(device, node, property)` member on `SchemaAdapter` — was rejected twice over: + +- It would put every curated control one argument away, and two of them do real work on the way out. `dominant_power_source_payload` translates `GRID` into the `ON_GRID` the v1.0 islanding assertion accepts, and `evse_charge_limit_payload` **refuses** a + value above the commissioned ceiling because publishing past it is the one write here with a physical consequence. +- `_derive_required_members` derives the required set from the protocol, so the member would be required of every adapter package. An installation carrying an older adapter wheel would fail at **discovery** — the whole integration, not one feature. + +No translation and no bounds check on the way out. Both exist on curated controls because this library knows what those properties mean; it knows nothing about an adopted one beyond its declaration. + ### Additive by construction `adopted_devices` defaults to `()`. schema_0 never populates it — flat has no device tree to find an unmodelled device in, and panels upgrade to v1.0 and stay there, so adoption operates in the schema that is the terminus. diff --git a/packages/schema-0/pyproject.toml b/packages/schema-0/pyproject.toml index 7496270..e50343e 100644 --- a/packages/schema-0/pyproject.toml +++ b/packages/schema-0/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api-schema-0" -version = "1.0.0b4" +version = "1.0.0b5" description = "Flat-schema (data-model-version absent) parser for span-panel-api" authors = [ {name = "SpanPanel"} diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index 04d323e..1d64a79 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api-schema-1" -version = "0.1.0b5" +version = "0.1.0b6" description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api" authors = [ {name = "SpanPanel"} diff --git a/packages/schema-1/src/span_panel_api_schema_1/adoption.py b/packages/schema-1/src/span_panel_api_schema_1/adoption.py index 6d00444..fda757c 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/adoption.py +++ b/packages/schema-1/src/span_panel_api_schema_1/adoption.py @@ -23,6 +23,8 @@ from span_panel_api.models import ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE, AdoptedDevice, AdoptedProperty from span_panel_api_schema_1.const import ( + HOMIE_DOMAIN, + HOMIE_VERSION, TYPE_BESS, TYPE_CIRCUIT, TYPE_EVSE, @@ -137,6 +139,7 @@ def _readings(device: DiscoveredDevice, declared_nodes: dict[str, dict[str, obje continue for property_id, definition in properties(node).items(): raw = device.get_property(node_id, property_id) + settable = bool(definition.get("settable", False)) readings.append( AdoptedProperty( node_id=node_id, @@ -144,8 +147,22 @@ def _readings(device: DiscoveredDevice, declared_nodes: dict[str, dict[str, obje datatype=str(definition.get("datatype") or "string"), unit=optional_str(definition.get("unit")), format=optional_str(definition.get("format")), - settable=bool(definition.get("settable", False)), + settable=settable, value=None if raw is None else str(raw), + set_topic=_set_topic(device.device_id, node_id, property_id) if settable else None, ) ) return tuple(readings) + + +def _set_topic(device_id: str, node_id: str, property_id: str) -> str: + """The Homie topic a write to one property is published to. + + The same three-part construction the adapter uses for every curated control, + repeated here rather than reached for, because that is the point: this + function is only ever called on a device `is_modelled` rejected and only for + a property the device declares settable, so no topic it can produce names + anything a curated setter owns. Sharing the adapter's builder would put the + whole address space one argument away. + """ + return f"{HOMIE_DOMAIN}/{HOMIE_VERSION}/{device_id}/{node_id}/{property_id}/set" diff --git a/pyproject.toml b/pyproject.toml index f6eb5b7..6b840c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "3.0.0b5" +version = "3.0.0b6" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} diff --git a/src/span_panel_api/__init__.py b/src/span_panel_api/__init__.py index 391f629..e9ce686 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -61,6 +61,7 @@ validate_solar_tabs, ) from .protocol import ( + AdoptedControlProtocol, CircuitControlProtocol, EvseControlProtocol, PanelCapability, @@ -80,6 +81,11 @@ # firmware publishes no such property, so the flat adapter answers None and # the transport refuses. "EvseControlProtocol", + # Added 2026-08-20 with device-scoped adoption: the first control whose + # subject this library does not understand. Additive, and authorised by the + # snapshot rather than by its arguments -- a device the adapter models + # produces no AdoptedDevice and so cannot be addressed through it. + "AdoptedControlProtocol", "PanelCapability", "PanelControlProtocol", "SpanPanelClientProtocol", diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 7283452..be31bdd 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -651,6 +651,25 @@ class AdoptedProperty: `None` when the property is declared and nothing has arrived. """ + set_topic: str | None = None + """The topic a write to this property is published to, or None. + + Populated **only** for a settable property on an adopted device, and that + scoping is the authorisation rather than a check somebody has to remember. + + The alternative -- a generic `set_property_topic(device, node, property)` on + the adapter -- would be a back door around every curated control, and the + bypass would skip real work: schema_1 has to translate `GRID` into `ON_GRID` + for the islanding assertion, and `evse_charge_limit_payload` *refuses* a + value above the commissioned ceiling because publishing past it is the one + write with a physical consequence. A topic that can only ever exist on a + device nothing models cannot be aimed at either. + + It also keeps this additive. A member on `SchemaAdapter` becomes required of + every adapter package, so an install carrying an older adapter wheel would + fail at *discovery* -- the whole integration, not one feature. + """ + @property def path(self) -> str: """`{node}/{property}` -- how the capability catalogs spell it.""" diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index bb439b7..8951d2a 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -28,7 +28,7 @@ SpanPanelStaleDataError, SpanPanelTimeoutError, ) -from ..models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot, V2HomieSchema +from ..models import AdoptedProperty, FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot, V2HomieSchema from ..protocol import PanelCapability, SchemaAdapter from .connection import AsyncMqttBridge from .const import MQTT_READY_TIMEOUT_S @@ -582,6 +582,56 @@ async def set_evse_charge_limit(self, node_id: str, amps: int) -> None: if self._bridge is not None: self._bridge.publish(topic, payload, qos=1) + # -- AdoptedControlProtocol -------------------------------------------- + + async def set_adopted_property(self, device_id: str, node_id: str, property_id: str, value: str) -> None: + """Publish a write to one settable property of an adopted device. + + Args: + device_id: the adopted device's wire id, as `AdoptedDevice.device_id` + node_id: the Homie node + property_id: the Homie property + value: the payload, already in the property's declared vocabulary + + **The lookup is the authorisation.** No topic is accepted from the + caller: this finds the property in the current snapshot's adopted + devices and publishes to the topic that property carries. A device the + adapter models has no `AdoptedDevice`, and a property the device does not + declare settable carries no `set_topic`, so neither can be reached from + here however the arguments are spelled. That is what keeps this from + being a generic write that routes around the curated setters -- which + would skip real work, since the islanding assertion needs its value + translated and the charge-current ceiling refuses values above what the + charger was commissioned for. + + No payload translation and no bounds check, deliberately. Both exist on + curated controls because this library knows what those properties mean. + It knows nothing about an adopted one beyond its declaration, and + inventing a bound would be inventing a fact about somebody's hardware. + The caller constrains the value to the declared `format`; the panel + remains the authority on whether to accept it. + """ + surface = self._adopted_property(device_id, node_id, property_id) + if surface is None or surface.set_topic is None: + raise SpanPanelServerError(f"No settable adopted property {node_id}/{property_id} on device {device_id!r}") + if self._bridge is not None: + self._bridge.publish(surface.set_topic, value, qos=1) + + def _adopted_property(self, device_id: str, node_id: str, property_id: str) -> AdoptedProperty | None: + """The named property of the named adopted device in the current snapshot. + + Built fresh rather than cached: a device that has left the tree must stop + being writable the moment it does, and a snapshot is the only thing that + knows. + """ + for device in self._require_adapter().build_snapshot().adopted_devices: + if device.device_id != device_id: + continue + for surface in device.properties: + if surface.node_id == node_id and surface.property_id == property_id: + return surface + return None + # -- StreamingCapableProtocol ------------------------------------------ def register_snapshot_callback( diff --git a/src/span_panel_api/protocol.py b/src/span_panel_api/protocol.py index c0cc49f..e92adaf 100644 --- a/src/span_panel_api/protocol.py +++ b/src/span_panel_api/protocol.py @@ -78,6 +78,26 @@ class EvseControlProtocol(Protocol): async def set_evse_charge_limit(self, node_id: str, amps: int) -> None: ... +@runtime_checkable +class AdoptedControlProtocol(Protocol): + """Control protocol for settable properties on a device nothing here models. + + Separate from the three above because its subject is different in kind. Those + name a control this library understands -- a relay, a shed priority, a charge + ceiling -- and translate or bound the value on the way out. This one names a + property by its wire address and passes the caller's value through, because + the declaration is all anybody here knows about it. + + The write is authorised by the snapshot rather than by the arguments: the + transport resolves the property against the current `adopted_devices` and + refuses anything it does not find carrying a set topic. A device this library + models produces no `AdoptedDevice` and so cannot be addressed here, which is + what stops this becoming a generic write around the curated setters. + """ + + async def set_adopted_property(self, device_id: str, node_id: str, property_id: str, value: str) -> None: ... + + @runtime_checkable class StreamingCapableProtocol(Protocol): """Push-based transport that delivers updates via callbacks.""" diff --git a/tests/test_adopted_control.py b/tests/test_adopted_control.py new file mode 100644 index 0000000..0e8db48 --- /dev/null +++ b/tests/test_adopted_control.py @@ -0,0 +1,120 @@ +"""Writing to an adopted property, and the ways that write refuses. + +The write exists so a control on a device nobody modelled is usable rather than +decorative. What matters here is the refusals: the write must not become a +generic one, because a generic write puts every curated setter one argument away +-- including the two that do real work on the way out, the islanding assertion +that translates its value and the charge ceiling that refuses one above what the +charger was commissioned for. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from span_panel_api.exceptions import SpanPanelServerError +from span_panel_api.models import AdoptedDevice, AdoptedProperty +from span_panel_api.mqtt import MqttClientConfig +from span_panel_api.mqtt.client import SpanMqttClient + +SERIAL = "sp3-242424-001" +DEVICE = "generator-1" + +CONTROL = AdoptedProperty( + node_id="generator", + property_id="mode", + datatype="enum", + format="AUTO,MANUAL,OFF", + settable=True, + value="AUTO", + set_topic=f"ebus/5/{DEVICE}/generator/mode/set", +) + +READING = AdoptedProperty(node_id="meter", property_id="active-power", datatype="float", unit="W", value="2400") + + +def _client(*properties: AdoptedProperty) -> tuple[SpanMqttClient, MagicMock]: + """A client whose adapter reports one adopted device carrying `properties`.""" + config = MqttClientConfig(broker_host="h", username="u", password="p") + client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + + adapter = MagicMock() + adapter.build_snapshot.return_value = MagicMock( + adopted_devices=(AdoptedDevice(device_id=DEVICE, device_type="energy.ebus.device.generator", properties=properties),) + ) + client._adapter = adapter + bridge = MagicMock() + client._bridge = bridge + return client, bridge + + +@pytest.mark.asyncio +async def test_a_settable_adopted_property_publishes_to_its_own_topic() -> None: + """The value passes through unchanged, which is the honest thing to do. + + This library knows nothing about an adopted property beyond its declaration, + so translating or bounding the value would be inventing a fact about somebody + else's hardware. The caller constrains it to the declared format; the panel + stays the authority on whether to accept it. + """ + client, bridge = _client(CONTROL, READING) + + await client.set_adopted_property(DEVICE, "generator", "mode", "OFF") + + bridge.publish.assert_called_once_with(f"ebus/5/{DEVICE}/generator/mode/set", "OFF", qos=1) + + +@pytest.mark.asyncio +async def test_a_property_carrying_no_set_topic_is_refused() -> None: + """A reading is not writable, and the absence of a topic is what says so.""" + client, bridge = _client(CONTROL, READING) + + with pytest.raises(SpanPanelServerError, match="No settable adopted property"): + await client.set_adopted_property(DEVICE, "meter", "active-power", "0") + + bridge.publish.assert_not_called() + + +@pytest.mark.asyncio +async def test_a_property_no_adopted_device_declares_is_refused() -> None: + """Arguments do not authorise the write; the snapshot does.""" + client, bridge = _client(CONTROL) + + with pytest.raises(SpanPanelServerError): + await client.set_adopted_property(DEVICE, "generator", "invented", "OFF") + + bridge.publish.assert_not_called() + + +@pytest.mark.asyncio +async def test_a_device_the_adapter_models_cannot_be_addressed_through_this() -> None: + """The whole reason the lookup is the authorisation. + + A circuit declares `switch/relay` settable and has a curated setter that owns + it. Spelling the circuit's id here reaches no `AdoptedDevice`, so there is + nothing to publish to -- not because a check rejected it, but because a + modelled device produces no adopted record to find. + """ + client, bridge = _client(CONTROL) + + with pytest.raises(SpanPanelServerError): + await client.set_adopted_property("aabbccdd112233445566778899001122", "switch", "relay", "OPEN") + + bridge.publish.assert_not_called() + + +@pytest.mark.asyncio +async def test_a_device_that_has_left_the_tree_stops_being_writable() -> None: + """Resolved against the current snapshot each time rather than cached. + + A control for a device that is no longer there must refuse rather than + publish into a topic nothing subscribes to. + """ + client, bridge = _client(CONTROL) + client._adapter.build_snapshot.return_value = MagicMock(adopted_devices=()) + + with pytest.raises(SpanPanelServerError): + await client.set_adopted_property(DEVICE, "generator", "mode", "OFF") + + bridge.publish.assert_not_called() diff --git a/tests/test_adoption.py b/tests/test_adoption.py index 0fc2b98..a9f5ed3 100644 --- a/tests/test_adoption.py +++ b/tests/test_adoption.py @@ -299,3 +299,57 @@ def test_the_snapshot_field_defaults_empty() -> None: every adapter package. """ assert SpanPanelSnapshot.__dataclass_fields__["adopted_devices"].default == () + + +# -- The set topic exists only where a write is legal ------------------------ + + +def test_a_settable_property_carries_the_topic_a_write_goes_to() -> None: + nodes = {"generator": {"properties": {"mode": {"datatype": "enum", "format": "AUTO,OFF", "settable": True}}}} + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE, nodes=nodes)) + + (control,) = _adopted(_snapshot(tree))["generator-1"].properties + assert control.set_topic == "ebus/5/generator-1/generator/mode/set" + + +def test_a_property_the_device_does_not_declare_settable_carries_no_topic() -> None: + """The absence is the authorisation, not a flag a caller is trusted to read. + + A consumer cannot construct a write for a property that carries no topic, so + "is this writable" is answered by the declaration once, here, rather than by + every caller remembering to ask. + """ + nodes = {"meter": {"properties": {"active-power": {"datatype": "float", "unit": "W"}}}} + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE, nodes=nodes)) + + (reading,) = _adopted(_snapshot(tree))["generator-1"].properties + assert reading.set_topic is None + + +def test_no_topic_reachable_this_way_can_name_a_modelled_device() -> None: + """The property that keeps this from being a generic write. + + A generic `set_property_topic(device, node, property)` would put every + curated control one argument away -- including the two that do real work on + the way out: the islanding assertion translates its value, and the charge + ceiling refuses one above what the charger was commissioned for. Because a + modelled device produces no `AdoptedDevice` at all, no topic produced here + can address one, whatever a caller passes. + """ + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE)) + snapshot = _snapshot(tree) + + addressable = {device.device_id for device in snapshot.adopted_devices for prop in device.properties if prop.set_topic} + modelled = {device_id for device_id in _tree() if device_id != PANEL} + assert not (addressable & modelled) + + +def test_a_settable_property_on_a_modelled_device_is_never_adopted_and_so_never_writable() -> None: + """Stated against a circuit, which really does declare settable properties. + + The reference tree's circuits declare `switch/relay` and `load-shed/priority` + settable, and both have curated setters. Adoption must not offer a second + route to either. + """ + snapshot = _snapshot(_tree()) + assert snapshot.adopted_devices == () diff --git a/tests/test_public_api_unchanged.py b/tests/test_public_api_unchanged.py index f9dd5b3..0fd1bf5 100644 --- a/tests/test_public_api_unchanged.py +++ b/tests/test_public_api_unchanged.py @@ -51,6 +51,7 @@ "ADOPTION_TOPOLOGY_NODE", "AdoptedDevice", "AdoptedProperty", + "AdoptedControlProtocol", "is_discovery_path", # Snapshots "SpanBatterySnapshot", diff --git a/uv.lock b/uv.lock index 35c942a..1cb1a56 100644 --- a/uv.lock +++ b/uv.lock @@ -1323,7 +1323,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "3.0.0b5" +version = "3.0.0b6" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -1380,7 +1380,7 @@ dev = [ [[package]] name = "span-panel-api-schema-0" -version = "1.0.0b4" +version = "1.0.0b5" source = { editable = "packages/schema-0" } dependencies = [ { name = "span-panel-api" }, @@ -1391,7 +1391,7 @@ requires-dist = [{ name = "span-panel-api", editable = "." }] [[package]] name = "span-panel-api-schema-1" -version = "0.1.0b5" +version = "0.1.0b6" source = { editable = "packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From ebfb8f1db7c09c2ff0309a0055aa0faf4bb99597 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:39:52 -0700 Subject: [PATCH 095/115] feat(schema-1): carry the proxy link an adopted device declares AdoptedDevice gains parent and proxied. Neither changes topology: an adopted device is still registered under the enclosure. They exist because a proxied unmodelled device is a real shape that would otherwise be flattened away without leaving evidence. The reference tree already contains one -- bess-mid declares parent: bess, the {proxier-id}-{proxied-id} naming of devices/proxy.md. proxied is derived against the tree root in the adapter rather than left to the consumer, because device ids are opaque and a consumer holding one device cannot tell the enclosure's id from a sibling's. The nesting is deliberately not built. python-sdk#49 records that proxied ids differ by design and that consumers correlate by info/serial-number rather than by device id, and ebus-sdk 0.21.0 shipped DeviceSpec/DeviceTreeBuilder (python-sdk#57) with the graph builder still to be reconciled against it. The tree model is being reshaped upstream, so the fields capture the evidence and the topology waits. --- CHANGELOG.md | 7 +++ DEVELOPMENT.md | 20 +++++++ .../src/span_panel_api_schema_1/adoption.py | 8 +++ src/span_panel_api/models.py | 35 +++++++++++ tests/test_adoption.py | 59 +++++++++++++++++++ 5 files changed, 129 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8675cbc..4999916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- **An adopted device carries the proxy link it declares: `AdoptedDevice.parent` and `AdoptedDevice.proxied`.** Carried rather than acted on — an adopted device is still registered under the enclosure — because a _proxied_ unmodelled device is a real shape + that would otherwise be flattened away unrecorded. The reference tree already contains one: `bess-mid` declares `parent: bess`, the `{proxier-id}-{proxied-id}` naming of `devices/proxy.md`. `proxied` is derived against the tree `root` in the adapter, + because device ids are opaque and a consumer holding one device cannot tell the enclosure's id from a sibling's. +- **The nesting is deliberately not built yet.** [python-sdk#49](https://github.com/electrification-bus/python-sdk/issues/49#issuecomment-5359203067) records that proxied ids differ by design and that consumers correlate by `info/serial-number` rather than + by device id, and `ebus-sdk` 0.21.0 shipped `DeviceSpec`/`DeviceTreeBuilder` ([python-sdk#57](https://github.com/electrification-bus/python-sdk/issues/57)) with the graph builder still to be reconciled against it. The tree model is being reshaped + upstream, so the fields capture the evidence and the topology waits. + - **A settable property on an adopted device can be written, and the write cannot reach anything else: `AdoptedProperty.set_topic` and `SpanMqttClient.set_adopted_property`.** The topic is populated only for a settable property on a device `is_modelled` rejects, so it is the scoping that authorises the write rather than a check a caller has to remember. The transport resolves the property against the current snapshot's `adopted_devices` and publishes to the topic that property carries; no topic is accepted from the caller, and a device this library models produces no `AdoptedDevice` to find. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d7d1a27..afbb74f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -176,6 +176,26 @@ The alternative — a `set_property_topic(device, node, property)` member on `Sc No translation and no bounds check on the way out. Both exist on curated controls because this library knows what those properties mean; it knows nothing about an adopted one beyond its declaration. +### The proxy link is carried, not acted on + +`AdoptedDevice.parent` holds the device id the device declares as its parent, and `AdoptedDevice.proxied` says whether that parent is a peer rather than the tree root. Neither changes topology: an adopted device is registered under the enclosure like every +other sub-device. + +They exist because a _proxied_ unmodelled device is a real shape and we would otherwise flatten it away without noticing. The reference tree already contains one — `bess-mid` declares `parent: bess`, which is the `{proxier-id}-{proxied-id}` naming of the +specification's `devices/proxy.md`. A vendor gateway proxying its own sub-devices arrives the same way, and the parent link is the only structural information about how they relate. + +`proxied` is computed here rather than left to the consumer because `root` is in hand here and is deliberately not carried onto the record: device ids are opaque, so a consumer holding one device cannot tell the enclosure's id from a sibling's. + +**Why the nesting is not built.** [python-sdk#49](https://github.com/electrification-bus/python-sdk/issues/49#issuecomment-5359203067) settled two things that bear on it. Proxied ids differ by design — the prefix is the proxier's own id, so several +enclosures on a shared broker each proxying the same physical device produce different ids on purpose, and consumers are told to correlate by `info/serial-number` and never by device id. And `ebus-sdk` 0.21.0 shipped `DeviceSpec` and `DeviceTreeBuilder` +([python-sdk#57](https://github.com/electrification-bus/python-sdk/issues/57)), with the maintainer's stated next step being to reconcile the existing graph builder against it rather than land both. + +So the tree model is under active reconciliation upstream. Carrying the two fields costs nothing and captures the evidence; building nesting semantics against a shape being reshaped this week would be building against a moving target. + +That same comment strengthens two choices already made here. Its deferral mechanism — `device_id` accepts a callable, `None` defers the device, and `resolve_deferred()` resumes when the identifier arrives — is the producer-side form of resolving identity +_before_ a device exists, which is what a consumer's freeze-at-first-sighting does from the other end. And "there is deliberately no existence predicate … expressing it by not calling `add()` is right" is the rule `TreeRoles` and the capability gates +already follow: presence in the tree is the signal, and there is no flag to consult. + ### Additive by construction `adopted_devices` defaults to `()`. schema_0 never populates it — flat has no device tree to find an unmodelled device in, and panels upgrade to v1.0 and stay there, so adoption operates in the schema that is the terminus. diff --git a/packages/schema-1/src/span_panel_api_schema_1/adoption.py b/packages/schema-1/src/span_panel_api_schema_1/adoption.py index fda757c..234a61d 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/adoption.py +++ b/packages/schema-1/src/span_panel_api_schema_1/adoption.py @@ -110,10 +110,18 @@ def card(property_id: str) -> str | None: return None return optional_str(device.get_property(ADOPTION_IDENTITY_NODE, property_id)) + parent = optional_str(description.get("parent")) + root = optional_str(description.get("root")) return AdoptedDevice( device_id=device.device_id, device_type=declared, name=optional_str(description.get("name")), + parent=parent, + # A peer proxies this device when its declared parent is something other + # than the tree root. Compared here because `root` is in hand here and is + # not carried onto the record: a consumer holding one device could not + # otherwise tell the enclosure's id from a sibling's, ids being opaque. + proxied=parent is not None and root is not None and parent != root, vendor_name=card(PROP_VENDOR_NAME), model=card(PROP_MODEL), serial_number=card(PROP_SERIAL_NUMBER), diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index be31bdd..91cc1ea 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -732,6 +732,41 @@ class AdoptedDevice: hardware_version: str | None = None """`info/hardware-version` -- for the device card.""" + parent: str | None = None + """The device id this device declares as its parent, verbatim. + + Carried rather than acted on. An adopted device is registered under the + enclosure like every other sub-device this library's consumers build, so this + field changes no topology today -- it exists so that the first real panel + carrying a *proxied* unmodelled device tells us its shape instead of having + it flattened away. + + That case is not hypothetical: the reference tree's own `bess-mid` declares + `parent: bess`, which is the specification's `{proxier-id}-{proxied-id}` + naming (`devices/proxy.md`). A vendor gateway proxying its own sub-devices + would arrive the same way, and the parent link is the only structural + information about how they relate. + + Not acted on *yet*, deliberately. `ebus-sdk` 0.21.0 introduced `DeviceSpec` + and `DeviceTreeBuilder` (python-sdk#57) and the maintainer's stated next step + is reconciling the existing graph builder against it rather than landing + both, so the tree model is being reshaped upstream. Building nesting + semantics against a shape under active reconciliation would be building + against a moving target; carrying the field costs nothing and captures the + evidence for when it settles. + """ + + proxied: bool = False + """Whether this device is proxied by a peer rather than by the enclosure. + + True when the declared `parent` is a device other than the tree root. The + distinction the raw `parent` cannot express on its own, because a consumer + holding one device has no way to tell the enclosure's id from a sibling's -- + ids are opaque by design, and per python-sdk#49 a proxied id's prefix is the + *proxier's* id, so the same physical device carries different ids under + different enclosures. + """ + properties: tuple[AdoptedProperty, ...] = () """Everything outside `info` and `connection`, in declaration order.""" diff --git a/tests/test_adoption.py b/tests/test_adoption.py index a9f5ed3..e01b6d1 100644 --- a/tests/test_adoption.py +++ b/tests/test_adoption.py @@ -52,6 +52,7 @@ def _device( name: str = "Backup Generator", nodes: dict[str, dict[str, dict[str, object]]] | None = None, values: dict[str, str] | None = None, + parent: str = PANEL, ) -> dict[str, str]: """One device's retained topics, as the broker hands them back. @@ -64,6 +65,8 @@ def _device( "type": device_type, "name": name, "nodes": nodes or {}, + "parent": parent, + "root": PANEL, } topics = {"$description": json.dumps(description), "$state": "ready"} topics.update(values or {}) @@ -353,3 +356,59 @@ def test_a_settable_property_on_a_modelled_device_is_never_adopted_and_so_never_ """ snapshot = _snapshot(_tree()) assert snapshot.adopted_devices == () + + +# -- The proxy link is carried, not acted on --------------------------------- + + +def test_a_device_the_enclosure_itself_declares_is_not_proxied() -> None: + """The ordinary case: a child of the tree root.""" + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE)) + + device = _adopted(_snapshot(tree))["generator-1"] + assert device.parent == PANEL + assert device.proxied is False + + +def test_a_device_proxied_by_a_peer_says_so() -> None: + """The shape the specification names, and the reason the field exists. + + The reference tree's own `bess-mid` declares `parent: bess` -- the + `{proxier-id}-{proxied-id}` naming of `devices/proxy.md`. A vendor gateway + proxying its own sub-devices arrives the same way, and the parent link is the + only structural information about how they relate. + """ + tree = _with(_tree(), "gateway-1", _device(UNMODELLED_TYPE, name="Vendor Gateway")) + tree = _with(tree, "gateway-1-sensor", _device(UNMODELLED_TYPE, name="Gateway Sensor", parent="gateway-1")) + + adopted = _adopted(_snapshot(tree)) + assert adopted["gateway-1"].proxied is False + assert adopted["gateway-1-sensor"].parent == "gateway-1" + assert adopted["gateway-1-sensor"].proxied is True + + +def test_the_parent_link_changes_no_topology_here() -> None: + """Carried, not acted on -- see `AdoptedDevice.parent`. + + Both devices are adopted as peers; nothing in this library nests one under + the other. Pinned so that if nesting is built later it is a deliberate change + with a test to update, rather than something that drifts in. + """ + tree = _with(_tree(), "gateway-1", _device(UNMODELLED_TYPE)) + tree = _with(tree, "gateway-1-sensor", _device(UNMODELLED_TYPE, parent="gateway-1")) + + assert {d.device_id for d in _snapshot(tree).adopted_devices} == {"gateway-1", "gateway-1-sensor"} + + +def test_a_description_declaring_no_parent_is_not_proxied() -> None: + """Absence is not a proxy claim. + + `proxied` requires both a parent and a root to compare it against, so a + partial description answers False rather than guessing. + """ + untyped = json.dumps({"homie": "5.0", "version": 1, "type": UNMODELLED_TYPE, "name": "Orphan", "nodes": {}}) + tree = _with(_tree(), "orphan-1", {"$description": untyped, "$state": "ready"}) + + device = _adopted(_snapshot(tree))["orphan-1"] + assert device.parent is None + assert device.proxied is False From 387bfbd294b2b9c49b3abbf7dcb24ebec22654f1 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:55:50 -0700 Subject: [PATCH 096/115] chore(deps): move to ebus-sdk 0.21.0 The schema-1 pin is already a range, so only the lock moves. 0.21.0 adds DeviceSpec and DeviceTreeBuilder and documents mqtt_cfg=None as the passive mode -- a tree that composes its description and resolves ids without opening a socket. Nothing here changes: 875 pass and mypy is clean on it. --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 1cb1a56..69433dc 100644 --- a/uv.lock +++ b/uv.lock @@ -504,14 +504,14 @@ wheels = [ [[package]] name = "ebus-sdk" -version = "0.19.0" +version = "0.21.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ebus-mqtt-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/45/044c4cd557850d7dc76e7ce664fedb5ce3ee4dbc2a58e97fb17e24991993/ebus_sdk-0.19.0.tar.gz", hash = "sha256:7987d3cae7c86e31656df9cd6e31e5a2ef950c757ba24f433adf019ca9aaa51c", size = 151155, upload-time = "2026-08-07T14:42:08.602Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/3c/784455cbe0e815359b32e2cd6807a889ef53e1bf84ed4210d668c2ee9dfb/ebus_sdk-0.21.0.tar.gz", hash = "sha256:3e6341cfcce9a4d9d37077b0e2edf4392cbe7ecace56596762da0b43914e4ea8", size = 194259, upload-time = "2026-08-20T15:38:15.654Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/86/aad23b5bd10abb72c3d6bc659ffd67f6b19384aa9b5507050df707ee6cbb/ebus_sdk-0.19.0-py3-none-any.whl", hash = "sha256:33aeec8d61b88373b8d1902bb8338449644d0d5aa4d75835567ad76ffebefd10", size = 95231, upload-time = "2026-08-07T14:42:07.211Z" }, + { url = "https://files.pythonhosted.org/packages/5c/91/42bed9e8b9f1f33adbf4a4e5768327166b8ab2cfc8b8f3af10bf93984925/ebus_sdk-0.21.0-py3-none-any.whl", hash = "sha256:ba0b8f1398e827defbad33f355260accc4ac52917044e96b7b1865fffed4a65a", size = 111554, upload-time = "2026-08-20T15:38:14.046Z" }, ] [[package]] From 482056eb7e0dad94c67a68bb2c89d1de83c29f4e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:44:30 -0700 Subject: [PATCH 097/115] chore(spec): re-vendor the catalogs at 4085c68, following the producer The peer-conformance check caught this rather than a person: panelbench moved its spec pin to 4085c68 and this repository still recorded 4254526, so the two sides were reading different vocabularies. That check has been loud since it was wired to fail rather than skip, and this is the first time it has fired on a real move. All sixteen catalogs re-vendored byte-for-byte from the specification at that commit, the peer record updated to panelbench 6c649e5, and nine capability versions read out of the catalogs rather than typed: breaker, connection, grid, grid-forming, power-flows and soc to 0.2, info to 0.3, switch to 0.3, meter to 0.4. Framework 0.7 -> 0.9. Nothing in the adapter changes. The catalogs are a validator, and the two divergences the register records are unaffected. --- packages/schema-1/spec/catalogs/breaker.json | 4 +-- .../schema-1/spec/catalogs/connection.json | 4 +-- .../schema-1/spec/catalogs/grid-forming.json | 8 +++--- packages/schema-1/spec/catalogs/grid.json | 4 +-- packages/schema-1/spec/catalogs/info.json | 4 +-- packages/schema-1/spec/catalogs/meter.json | 9 ++++-- .../schema-1/spec/catalogs/power-flows.json | 12 ++++---- packages/schema-1/spec/catalogs/soc.json | 4 +-- packages/schema-1/spec/catalogs/switch.json | 9 ++++-- .../span_panel_api_schema_1/spec_lock.json | 28 +++++++++---------- 10 files changed, 48 insertions(+), 38 deletions(-) diff --git a/packages/schema-1/spec/catalogs/breaker.json b/packages/schema-1/spec/catalogs/breaker.json index 0c9e8a1..b3438fa 100644 --- a/packages/schema-1/spec/catalogs/breaker.json +++ b/packages/schema-1/spec/catalogs/breaker.json @@ -3,9 +3,9 @@ "schema_version": "property-schema-v1", "kind": "capability-catalog", "capability": "energy.ebus.capability.breaker", - "version": "0.1", + "version": "0.2", "status": "DRAFT", - "date": "2026-07-11", + "date": "2026-08-20", "properties": { "rating": { "datatype": "integer", diff --git a/packages/schema-1/spec/catalogs/connection.json b/packages/schema-1/spec/catalogs/connection.json index d4c40a7..fad9721 100644 --- a/packages/schema-1/spec/catalogs/connection.json +++ b/packages/schema-1/spec/catalogs/connection.json @@ -3,9 +3,9 @@ "schema_version": "property-schema-v1", "kind": "capability-catalog", "capability": "energy.ebus.capability.connection", - "version": "0.1", + "version": "0.2", "status": "DRAFT", - "date": "2026-07-05", + "date": "2026-08-20", "properties": { "feeds-device-id": { "datatype": "string", diff --git a/packages/schema-1/spec/catalogs/grid-forming.json b/packages/schema-1/spec/catalogs/grid-forming.json index 0586814..5872568 100644 --- a/packages/schema-1/spec/catalogs/grid-forming.json +++ b/packages/schema-1/spec/catalogs/grid-forming.json @@ -3,19 +3,19 @@ "schema_version": "property-schema-v1", "kind": "capability-catalog", "capability": "energy.ebus.capability.grid-forming", - "version": "0.1", + "version": "0.2", "status": "DRAFT", - "date": "2026-07-11", + "date": "2026-08-20", "properties": { "capable": { "datatype": "boolean", "req": "MUST", - "description": "Static hardware capability: does this inverter support grid-forming operation at all? (when the capability is published)" + "description": "Static hardware capability: does this inverter support grid-forming operation at all? A publisher that does not know it does not publish this node; see §\"Absence semantics\"." }, "active": { "datatype": "boolean", "req": "SHOULD", - "description": "Current state: is this inverter actively grid-forming right now? When `false` and the inverter is energized, it is grid-following. (when `capable = true`)" + "description": "Current state: is this inverter actively grid-forming right now? When `false` and the inverter is energized, it is grid-following. Meaningful only when `capable = true`." } } } diff --git a/packages/schema-1/spec/catalogs/grid.json b/packages/schema-1/spec/catalogs/grid.json index 8545bc2..e6d486f 100644 --- a/packages/schema-1/spec/catalogs/grid.json +++ b/packages/schema-1/spec/catalogs/grid.json @@ -3,9 +3,9 @@ "schema_version": "property-schema-v1", "kind": "capability-catalog", "capability": "energy.ebus.capability.grid", - "version": "0.1", + "version": "0.2", "status": "DRAFT", - "date": "2026-07-11", + "date": "2026-08-20", "properties": { "islanding-state": { "datatype": "enum", diff --git a/packages/schema-1/spec/catalogs/info.json b/packages/schema-1/spec/catalogs/info.json index 6ab0569..a9a0be6 100644 --- a/packages/schema-1/spec/catalogs/info.json +++ b/packages/schema-1/spec/catalogs/info.json @@ -3,9 +3,9 @@ "schema_version": "property-schema-v1", "kind": "capability-catalog", "capability": "energy.ebus.capability.info", - "version": "0.2", + "version": "0.3", "status": "DRAFT", - "date": "2026-07-30", + "date": "2026-08-20", "properties": { "vendor-name": { "datatype": "string", diff --git a/packages/schema-1/spec/catalogs/meter.json b/packages/schema-1/spec/catalogs/meter.json index dafe790..9f2586e 100644 --- a/packages/schema-1/spec/catalogs/meter.json +++ b/packages/schema-1/spec/catalogs/meter.json @@ -3,9 +3,9 @@ "schema_version": "property-schema-v1", "kind": "capability-catalog", "capability": "energy.ebus.capability.meter", - "version": "0.2", + "version": "0.4", "status": "DRAFT", - "date": "2026-07-31", + "date": "2026-08-20", "properties": { "active-power": { "datatype": "float", @@ -84,6 +84,11 @@ "unit": "VAh", "req": "MAY", "description": "Cumulative apparent energy exported." + }, + "shared-with-device-ids": { + "datatype": "string", + "req": "MAY", + "description": "Comma-separated Homie device IDs of the other devices this meter's hardware also measures. Omitted when it measures only this device. See §\"Shared metering hardware\"." } }, "property_patterns": { diff --git a/packages/schema-1/spec/catalogs/power-flows.json b/packages/schema-1/spec/catalogs/power-flows.json index 410ca8f..002b2f8 100644 --- a/packages/schema-1/spec/catalogs/power-flows.json +++ b/packages/schema-1/spec/catalogs/power-flows.json @@ -3,33 +3,33 @@ "schema_version": "property-schema-v1", "kind": "capability-catalog", "capability": "energy.ebus.capability.power-flows", - "version": "0.1", + "version": "0.2", "status": "DRAFT", - "date": "2026-07-11", + "date": "2026-08-20", "properties": { "grid": { "datatype": "float", "unit": "W", "req": "SHOULD", - "description": "Grid power flow (positive = importing from grid)." + "description": "Grid power flow (positive = exporting to the grid)." }, "battery": { "datatype": "float", "unit": "W", "req": "SHOULD", - "description": "Battery power flow (positive = discharging)." + "description": "Battery power flow (positive = charging)." }, "pv": { "datatype": "float", "unit": "W", "req": "SHOULD", - "description": "Solar PV power flow (positive = producing)." + "description": "Solar PV power flow (negative while producing)." }, "site": { "datatype": "float", "unit": "W", "req": "SHOULD", - "description": "Total site power consumption." + "description": "Total site power consumption (positive = consuming)." } } } diff --git a/packages/schema-1/spec/catalogs/soc.json b/packages/schema-1/spec/catalogs/soc.json index 2885427..c26e46c 100644 --- a/packages/schema-1/spec/catalogs/soc.json +++ b/packages/schema-1/spec/catalogs/soc.json @@ -3,9 +3,9 @@ "schema_version": "property-schema-v1", "kind": "capability-catalog", "capability": "energy.ebus.capability.soc", - "version": "0.1", + "version": "0.2", "status": "DRAFT", - "date": "2026-07-11", + "date": "2026-08-20", "properties": { "soc": { "datatype": "float", diff --git a/packages/schema-1/spec/catalogs/switch.json b/packages/schema-1/spec/catalogs/switch.json index 13d44cb..46a3f1e 100644 --- a/packages/schema-1/spec/catalogs/switch.json +++ b/packages/schema-1/spec/catalogs/switch.json @@ -3,9 +3,9 @@ "schema_version": "property-schema-v1", "kind": "capability-catalog", "capability": "energy.ebus.capability.switch", - "version": "0.1", + "version": "0.3", "status": "DRAFT", - "date": "2026-07-05", + "date": "2026-08-20", "properties": { "relay": { "datatype": "enum", @@ -24,6 +24,11 @@ "format": "USER,LOAD_SHED,PCS,CONFIGURATION,FAULT,NONE,UNKNOWN", "req": "SHOULD", "description": "Source attribution for the last relay change: `USER`, `LOAD_SHED`, `PCS`, `CONFIGURATION`, `FAULT`, `NONE`, `UNKNOWN`. Publishers MAY extend via `$format`." + }, + "shared-with-device-ids": { + "datatype": "string", + "req": "MAY", + "description": "Comma-separated Homie device IDs of the other devices this relay also switches. Omitted when it switches only this device. See §\"Shared switching hardware\"." } } } diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json index b3f14bb..e2deb01 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -7,15 +7,15 @@ "data_model_version": ">=1.0,<2.0" }, "spec_repo": "https://github.com/electrification-bus/specification", - "synced_commit": "4254526b0a8c11cab0d40fd700b1fc295c0479c6", - "synced_date": "2026-08-06", - "framework": "0.7", + "synced_commit": "4085c684f9a79bb3c25086112ba08c1f967f63c8", + "synced_date": "2026-08-20", + "framework": "0.9", "peer": { "repo": "https://github.com/SpanPanel/panelbench", "ref": "main", "role": "publisher", - "commit": "0870dfd21ac0557065c5219d27825e0222e2740a", - "synced_commit": "4254526b0a8c11cab0d40fd700b1fc295c0479c6", + "commit": "6c649e53572577db81d7806dbbb79b9dd712abb2", + "synced_commit": "4085c684f9a79bb3c25086112ba08c1f967f63c8", "firmware_range": "r202633+", "fixtures": { "tree": "tests/conformance/fixtures/golden_tree.json", @@ -24,22 +24,22 @@ }, "implements": { "capabilities": { - "breaker": "0.1", + "breaker": "0.2", "charge-limit": "0.1", - "connection": "0.1", + "connection": "0.2", "door": "0.1", - "grid": "0.1", - "grid-forming": "0.1", - "info": "0.2", + "grid": "0.2", + "grid-forming": "0.2", + "info": "0.3", "load-shed": "0.3", - "meter": "0.2", + "meter": "0.4", "pcs": "0.3", - "power-flows": "0.1", + "power-flows": "0.2", "shed": "0.2", "shed-forecast": "0.1", - "soc": "0.1", + "soc": "0.2", "status": "0.1", - "switch": "0.1" + "switch": "0.3" }, "devices": { "distribution-enclosure": "0.12", From 5e10898488ae12c60524c8dc3d6d98dfeb93a2b9 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:07:00 -0700 Subject: [PATCH 098/115] docs(devices): correct the power-flows frame, and name the BESS assumption Two comment corrections, no behaviour change. power-flows/battery is charge-positive. The catalog said discharge-positive when this was written; capabilities/power-flows.md 0.2 corrected it to the frame the firmware always published. Pass-through was right either way, so only the stated reason was stale -- but a reader deriving a sign from that sentence would have got it backwards. _charge_positive assumes the BESS child publishes its own meter spec-conformantly, discharge-positive. The eBus maintainer's r202633 conformance note says SPAN does not: it publishes charge-positive, so power_w inverts on that firmware. Named rather than compensated, because the simulator was fixed to be spec-conformant and the panel was not, so no test here can see it and a live panel is what should settle it. --- .../src/span_panel_api_schema_1/devices.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/devices.py b/packages/schema-1/src/span_panel_api_schema_1/devices.py index 339844d..c0a9bd4 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/devices.py +++ b/packages/schema-1/src/span_panel_api_schema_1/devices.py @@ -25,10 +25,19 @@ exactly this reason, and ``build_battery`` does the same, so the snapshot's rule holds everywhere: positive means power flowing into the metered device. -Note the enclosure's own ``power-flows/battery`` uses the opposite convention -(the capability catalog defines it as discharge-positive) and is passed through -untouched into ``panel.power_flow_battery``. Same physical power, opposite -frames; ``battery.power_w`` is the one already in the snapshot's frame. +Note the enclosure's own ``power-flows/battery`` is passed through untouched +into ``panel.power_flow_battery``, and is **charge-positive**: power flowing out +of the site node toward the battery. The catalog said discharge-positive when +this was written; `capabilities/power-flows.md` 0.2 corrected it to the frame +the firmware always published. Pass-through is right either way — only the +reason changed. ``battery.power_w`` is the one this module puts in the +snapshot's frame. + +The conversion below assumes the BESS child publishes its own meter +spec-conformantly, i.e. discharge-positive. SPAN r202633 does not: it publishes +charge-positive, so ``power_w`` inverts on that firmware. See the r202633 +conformance note in the consumer's delta document; unresolved, and deliberately +not compensated here until a live panel confirms it. """ from __future__ import annotations From 231f73c7307c14dda9f49514af955b89f3cd9a58 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:59:55 -0700 Subject: [PATCH 099/115] docs(devices): name the battery frame for what it produces _charge_positive produced discharge-positive values, and said the opposite in its name, its docstring and the module rule above it. Renamed to _discharge_positive; the value itself does not move. That value is correct. Positive means power flowing out of the battery, which is the frame the eBus specification asks of a device's own meter. What was wrong was the claim that the into-the-device rule the circuit fields follow held here too: the wire inputs are in opposite frames, so the same single negation lands the two conventions on opposite results. Settled by measurement rather than by reading a catalog. A producer driven into self-consumption with the grid at zero -- PV 4181 W plus battery 1917 W meeting a 6099 W load -- leaves no room to argue which way the battery is going. The wire publishes -1917.49 and this reports +1917.49 while discharging. The module docstring also records what the same run confirmed about the two wire properties: power-flows/battery and the BESS meter carry the SAME sign as each other, which is the specification's violation rather than its rule, and is therefore the discriminator that would tell a consumer which firmware it is on. --- .../src/span_panel_api_schema_1/devices.py | 62 ++++++++++++------- .../span_panel_api_schema_1/field_metadata.py | 4 +- src/span_panel_api/models.py | 17 +++-- tests/test_schema_one_devices.py | 27 +++++--- 4 files changed, 72 insertions(+), 38 deletions(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/devices.py b/packages/schema-1/src/span_panel_api_schema_1/devices.py index c0a9bd4..61bf054 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/devices.py +++ b/packages/schema-1/src/span_panel_api_schema_1/devices.py @@ -19,25 +19,36 @@ (``connected`` and ``communication_state``), because they answer different questions: the panel's view of the link, and the publisher's view of its own. -**Battery power is charge-positive, and the wire is not.** The enclosure meters -the BESS the way it meters a circuit — a device it feeds — so a charging battery -publishes a *negative* ``meter/active-power``. ``build_circuit`` negates for -exactly this reason, and ``build_battery`` does the same, so the snapshot's rule -holds everywhere: positive means power flowing into the metered device. - -Note the enclosure's own ``power-flows/battery`` is passed through untouched -into ``panel.power_flow_battery``, and is **charge-positive**: power flowing out -of the site node toward the battery. The catalog said discharge-positive when -this was written; `capabilities/power-flows.md` 0.2 corrected it to the frame -the firmware always published. Pass-through is right either way — only the -reason changed. ``battery.power_w`` is the one this module puts in the -snapshot's frame. - -The conversion below assumes the BESS child publishes its own meter -spec-conformantly, i.e. discharge-positive. SPAN r202633 does not: it publishes -charge-positive, so ``power_w`` inverts on that firmware. See the r202633 -conformance note in the consumer's delta document; unresolved, and deliberately -not compensated here until a live panel confirms it. +**Battery power is discharge-positive, and that is not the rule circuits follow.** +``build_circuit`` negates so that positive means power flowing *into* the metered +device, which is the convention the rest of this module states. ``build_battery`` +negates too, but its wire input is in the opposite frame, so it lands on the +opposite result: ``battery.power_w`` is positive while the battery *discharges*. + +Measured rather than reasoned. Driving the producer into self-consumption with +the grid at zero forces the direction: PV 4181 W plus battery 1917 W meeting a +6099 W load leaves nothing ambiguous, and the battery is discharging. The wire +publishes ``-1917.49`` and this module reports ``+1917.49``. + +That value is *correct* -- it is the frame the eBus specification asks for from a +device's own meter, "positive while discharging, that is, power flowing out of +the device". What was wrong was the name: this used to be ``_charge_positive``, +and the sentence above used to claim the into-the-device rule held everywhere. +It does not hold for the battery, and saying so was the defect. + +Both wire properties behind it carry the *same* sign as each other -- a live +panel capture and ``ebus-panel-sim`` 0.6.0 both publish the pair identically -- +so ``panel.power_flow_battery`` (passed through untouched) and +``battery.power_w`` (negated here) end up as each other's mirror, and a consumer +showing both sees one convention after applying one negation to either. + +Note that the alignment of those two wire properties is the specification's +*violation* rather than its rule: the spec defines ``power-flows/battery`` as the +negation of the BESS meter, and this firmware publishes them equal. Comparing the +two therefore tells a consumer which firmware it is on -- equal means today's, +opposite means a conformant future one -- which is what would let this conversion +stay correct across that change. Undecidable while the battery is idle and both +read zero. """ from __future__ import annotations @@ -175,8 +186,15 @@ def _connected(status: str | None) -> bool | None: return None if status is None else status == STATUS_OK -def _charge_positive(raw_power_w: float | None) -> float | None: - """Flip the enclosure's meter frame to the snapshot's charge-positive one. +def _discharge_positive(raw_power_w: float | None) -> float | None: + """Flip the enclosure's meter frame to the BESS device's own. + + Positive means power flowing *out of the battery*, which is discharging, and + which is what the eBus specification asks of a device's own meter. Named for + what it produces after being called `_charge_positive` for as long as it + produced the opposite -- measured against a producer driven into + self-consumption, where the grid sits at zero and the direction cannot be + argued. `None` stays `None`: a BESS that publishes no `meter` node has no power reading, which is not the same as zero. The `0.0` guard is `build_circuit`'s, @@ -212,7 +230,7 @@ def build_battery(bess: DiscoveredDevice | None, owners: list[DiscoveredDevice]) nameplate_capacity_kwh=number(bess, NODE_INFO, PROP_NAMEPLATE_CAPACITY), # None when unclaimed, so "nobody has said" stays distinct from "not OK". connected=_connected(status), - power_w=_charge_positive(number(bess, NODE_METER, PROP_ACTIVE_POWER)), + power_w=_discharge_positive(number(bess, NODE_METER, PROP_ACTIVE_POWER)), # The BESS's own link health, kept as the published enum string rather # than collapsed to a bool: DEGRADED is neither OK nor LOST, and a bool # would have to pick one. diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index 646e004..c8283fc 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -158,8 +158,8 @@ (TYPE_BESS, NODE_INFO, "firmware-version", "battery.software_version"), (TYPE_BESS, NODE_INFO, "nameplate-capacity", "battery.nameplate_capacity_kwh"), # The BESS's own meter and its own link health. `battery.power_w` carries a - # sign flip (`build_battery` reports charge-positive, the wire is - # charge-negative), which does not affect the unit or the datatype this row + # sign flip (`build_battery` reports discharge-positive, the wire carries the + # enclosure's frame), which does not affect the unit or the datatype this row # describes — a row states what the property *is*, not what the mapper does # with it. (TYPE_BESS, NODE_METER, "active-power", "battery.power_w"), diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 91cc1ea..c96eee4 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -375,14 +375,19 @@ class SpanBatterySnapshot: # meters a circuit, so a charging battery reads negative there and positive # here, exactly as `SpanCircuitSnapshot.instant_power_w` reports a load's # consumption positive. The snapshot's rule across every power field is that - # positive means power flowing *into* the metered device. + # positive means power flowing *out of* the battery, which is discharging. + # That is the frame the eBus specification asks of a device's own meter, and + # it is deliberately NOT the into-the-device rule the circuit fields follow: + # the wire input is in the opposite frame, so one negation lands here rather + # than there. Measured against a producer in self-consumption with the grid + # at zero, where the direction cannot be argued. # # Distinct from `SpanPanelSnapshot.power_flow_battery`, which is the - # enclosure's own arbitrated flow figure and is passed through in the - # publisher's discharge-positive frame. The two describe the same physical - # power in opposite frames, so a consumer rendering both must negate one of - # them; this one is already negated. - power_w: float | None = None # v2: bess meter/active-power (W), charge-positive + # enclosure's own arbitrated flow figure, passed through untouched and + # charge-positive. The two describe the same physical power in opposite + # frames, so a consumer rendering both must negate one of them; this one is + # already negated. + power_w: float | None = None # v2: bess meter/active-power (W), discharge-positive # `status/communication-state`, v1.0 only: the BESS publisher's report of its # own link health (OK/DEGRADED/LOST/UNKNOWN). **Not** `connected`, which is diff --git a/tests/test_schema_one_devices.py b/tests/test_schema_one_devices.py index 8f23103..63b77bc 100644 --- a/tests/test_schema_one_devices.py +++ b/tests/test_schema_one_devices.py @@ -215,14 +215,25 @@ def test_the_capture_is_a_charging_battery() -> None: assert float(_published("bess", BESS_POWER_TOPIC)) < 0 -def test_battery_power_is_charge_positive() -> None: - """The snapshot's frame: positive means power flowing into the metered device. - - Same rule as `SpanCircuitSnapshot.instant_power_w`, and reached the same way - -- `build_circuit` negates the enclosure's meter for a load, and a charging - BESS is a load. Asserting the magnitude and the sign separately is deliberate: - dropping the negation keeps the magnitude and fails here on the sign, which is - the mistake worth catching. +def test_battery_power_is_the_negation_of_the_wire() -> None: + """One negation, and the frame it lands in is the BESS device's own. + + Positive means power flowing *out of* the battery -- discharging -- which is + what the eBus specification asks of a device's own meter, and deliberately + NOT the into-the-device rule `SpanCircuitSnapshot.instant_power_w` follows. + The wire inputs are in opposite frames, so the same single negation lands the + two fields on opposite conventions. This test used to claim the circuit rule + held here too; it does not, and the helper was renamed from + `_charge_positive` to `_discharge_positive` to stop implying it. + + Asserted against the wire rather than against a constant, and the sign + separately from the magnitude: dropping the negation keeps the magnitude and + fails on the sign, which is the mistake worth catching. + + The direction was settled by measurement rather than by reading the catalog + -- a producer in self-consumption with the grid at zero, PV and battery + together meeting the load, leaves no room to argue which way the battery is + going. """ raw = float(_published("bess", BESS_POWER_TOPIC)) From ebcc351982c8ccd98f7dde435dba44407c03ae30 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:04:02 -0700 Subject: [PATCH 100/115] fix(packaging): bound httpx, declare adapter extras, and stop shipping dev tooling Release-readiness review before publishing three distributions to PyPI. Four of these cannot be fixed after a version is published, which is why they are here rather than in the next one. **httpx was unbounded and httpx 1.0 removes `AsyncClient`.** 1.0.dev1 through dev4 are on PyPI now, and every distribution here is a prerelease, so `pip install --pre` -- the verb RELEASE.md itself prescribes for verifying a release -- resolves them. `_http.py` constructs `httpx.AsyncClient` at runtime, so auth, detection, `get_homie_schema` and the redispatch refetch all raise AttributeError. Reproduced in a clean venv: 55 failures, every one the same missing attribute. `paho-mqtt` has been bounded from the start; this was the one unbounded runtime dependency. **Upgrading the bootstrap alone bricked an existing install, and pip reported success.** `SchemaAdapter` gained two members after b3, and `_derive_required_members` makes every public member mandatory of every adapter wheel, so b6 with b3 adapters rejects both at discovery and no panel of any generation connects. The contract integer cannot help: it is declared by the adapter, so bumping it produces the same rejection at the same moment. Neither mechanism can reach pip. Extras can, and extras cannot be retrofitted to a published version, so they go in now. **The eBus SDK ceiling tightens to `<0.23`**, the versions actually tested. 0.22 was read module by module first and changes only publisher-side code -- `adapter.py`, `topology.py`, `transport.py` and `property.py` are byte-identical to 0.21 -- so this bounds exposure rather than reporting breakage. 0.x carries no compatibility contract and this release reaches hosts that are slow to iterate; widening a ceiling later is a patch release, narrowing one after a host has already resolved a bad pairing is not. **The wheel shipped `scripts/` at the top level of site-packages**, so an unrelated `import scripts` in a Home Assistant venv resolved to this distribution, and a markdown formatter installed as a console script for every user. **The sdist shipped both adapters in full**, contradicting the one invariant this distribution is built around. Both are now explicit, and the sdist includes are anchored -- an unanchored `README.md` is a glob that matches at any depth and pulled each adapter's metadata back in. **A redispatch could fire twice for one firmware upgrade.** The in-flight guard was released when the schema fetch finished rather than when the swap did, and the slowest step sits inside that window: `_preload_adapter` imports the new parser in a thread and takes seconds on a cold start, during which the recorded generation is still the old one. A second retained `data-model-version` message, or the connect edge, scheduled a second redispatch. The consumer reloads its config entry off that callback, so it was a reload racing its own teardown -- during precisely the upgrade this release exists to support. **The changelog documented the battery sign backwards.** It said `power_w` is charge-positive and asserted a deliberate asymmetry with `power_flow_battery`. The code says the opposite and the code is right: measured with the producer in self-consumption and the grid at exactly zero, the wire read -1917.49 while the battery discharged and the snapshot reported +1917.49. The two frames agree rather than oppose. Whoever built a battery sensor from that note would have shipped an inverted entity. The rename that settled it is now recorded in schema-1, where the behaviour lives. All three changelogs gained a section for the version being published; schema-0 and schema-1 had shipped b4 and b5 with none, and schema-0's floor claim still named 3.0.0b2 against a manifest requiring b4. One surviving mutation closed: replacing the device-id filter in `_adopted_property` with a constant left the entire suite green. The lookup returns the first device carrying the node and property asked for, so without it a write aimed at one adopted generator publishes to another's topic. Every existing test used a single adopted device, where the filter cannot be wrong because there is nothing else to match. --- CHANGELOG.md | 9 ++-- packages/schema-0/CHANGELOG.md | 11 +++++ packages/schema-1/CHANGELOG.md | 16 ++++++++ packages/schema-1/pyproject.toml | 10 ++++- pyproject.toml | 48 +++++++++++++++++++--- scripts/format.sh | 2 +- src/span_panel_api/mqtt/client.py | 16 +++++++- tests/test_adopted_control.py | 68 +++++++++++++++++++++++++++++++ uv.lock | 15 ++++++- 9 files changed, 181 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4999916..e755d9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [3.0.0b6] ### Added @@ -72,9 +72,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - **The BESS's own meter and link health reach the snapshot: `SpanBatterySnapshot.power_w` and `SpanBatterySnapshot.communication_state`.** The battery device has published `meter/active-power` and `status/communication-state` all along and neither reached a field, so a consumer could show the enclosure's arbitrated `power_flow_battery` and nothing the BESS itself reports. Both are `None` on a BESS that publishes no such node, and on every flat panel — the flat schema's BESS device class declares neither property, so this is new surface rather than a re-sourcing, and nothing that exists today changes. -- **`power_w` is charge-positive, and the wire is not.** The enclosure meters the BESS the way it meters a circuit it feeds, so a charging battery publishes a _negative_ `meter/active-power`; `build_battery` negates it, exactly as `build_circuit` does for - a load, so the snapshot's rule holds on every power field: positive means power flowing into the metered device. Note the deliberate asymmetry with `panel.power_flow_battery`, which the capability catalog defines as discharge-positive and which both - adapters pass through untouched. The two describe the same physical power in opposite frames; `battery.power_w` is the one already in the snapshot's frame, so a consumer rendering both negates the other. +- **`power_w` is discharge-positive, and the wire is not.** The enclosure meters the BESS the way it meters a circuit it feeds, so a _discharging_ battery publishes a negative `meter/active-power`; `build_battery` negates it, exactly as `build_circuit` + does for a load. Positive therefore means the battery is supplying power, which is the frame `panel.power_flow_battery` already uses and which the capability catalog defines — the two agree rather than opposing each other, so a consumer rendering both + negates neither. This entry said the opposite until the direction was settled by measurement rather than by reading: with the producer driven into self-consumption and the grid at exactly zero, the wire read `-1917.49` and the snapshot reported + `+1917.49` while the battery was discharging. `_charge_positive` was renamed `_discharge_positive` in the same pass, and the convention now matches `pv_power` positive-while-producing and `grid_power_flow` positive-while-importing. - **`communication_state` stays the published enum string** (`OK`/`DEGRADED`/`LOST`/`UNKNOWN`) rather than collapsing to a bool: `DEGRADED` is neither `OK` nor `LOST`, and a bool would have to pick one. It is deliberately not merged into `battery.connected`, which is the _enclosure's_ `connection/fed-by-device-status` view of the same link. One is the device speaking about itself and the other the panel speaking about it, and the migration guide warns against conflating them. - **`_PROPERTY_FIELD_MAP` rows for both**, which buys them the unit and datatype the BESS's own `$description` declares plus the three-way resolution contract — a BESS that publishes the node while omitting the property reports degradation rather than diff --git a/packages/schema-0/CHANGELOG.md b/packages/schema-0/CHANGELOG.md index 2ddf70f..c176950 100644 --- a/packages/schema-0/CHANGELOG.md +++ b/packages/schema-0/CHANGELOG.md @@ -7,6 +7,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is fixed — the flat single-device schema, SPAN firmware `r202603` through `r202627` — and is identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version number. A release here means this parser changed, never that the panel did. +## [1.0.0b5] - 08/2026 + +Pre-release. Requires `span-panel-api` 3.0.0b4 or newer, which is the floor the manifest has carried since the bootstrap grew the EVSE charge-limit members. + +### Added + +- **`set_evse_charge_limit_topic` and `evse_charge_limit_payload`.** Both required of every adapter, because `SchemaAdapter` gained them: `_derive_required_members` makes each public protocol member mandatory of every adapter wheel, so an adapter without + them is rejected at discovery no matter which panel it would have parsed. Flat firmware publishes no charge-limit surface, so this distribution answers for the absence rather than for a topic — the point being that answering is not optional. +- **`adopted_devices` reports empty.** Adoption is a parent/child idea: a flat panel is one device with no unmodelled children to adopt, so the honest answer is a stable empty tuple rather than an unimplemented member. `set_adopted_property` therefore + raises on a flat panel for the same reason it raises for a device that does not exist, which is what makes the snapshot lookup an authorization rather than a lookup. + ## [1.0.0b3] - 08/2026 Pre-release. Requires `span-panel-api` 3.0.0b2 or newer — unchanged, because nothing added here reaches for anything newer. diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index af8c06e..a401004 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -7,6 +7,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is the parent/child device tree SPAN firmware `r202633+` publishes, identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version number. A release here means this parser changed, never that the panel did. +## [0.1.0b6] - 08/2026 + +Pre-release. Requires `span-panel-api` 3.0.0b4 or newer — unchanged. The eBus SDK ceiling tightens to `<0.23`, the versions actually tested: 0.x carries no compatibility contract, and 0.22 was read module by module before the bound was set — it changes +only publisher-side code, leaving `adapter.py`, `topology.py`, `transport.py` and `property.py` byte-identical to 0.21. + +### Fixed + +- **The BESS meter is discharge-positive, and was named for the opposite.** `_charge_positive` is renamed `_discharge_positive`. No published value changes — the negation was always right — but the name asserted a direction that the wire does not carry, + and the root changelog documented that wrong direction as fact. Settled by measurement rather than by reading: with the producer in self-consumption and the grid at exactly zero, `pv −4181.34 + battery −1917.49 + grid −0.0 + site +6098.83 = 0`, so the + battery was discharging at 1917 W and both `battery.power_w` and `bess_meter_power` reported `+1917.49`. The convention therefore matches `pv_power` positive-while-producing and `grid_power_flow` positive-while-importing, and agrees with + `panel.power_flow_battery` rather than opposing it. + +### Added + +- **`adoption`, building `AdoptedDevice` records for device types this parser does not model**, with `set_topic` populated only where the declaration says the property is settable. Subtype-aware, so a curated device never lands in `adopted_devices`. + ## [0.1.0b5] - 08/2026 Pre-release. Requires `span-panel-api` 3.0.0b4 or newer — unchanged, because nothing added here reaches for anything newer. diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index 1d64a79..e9cbe98 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -19,7 +19,15 @@ dependencies = [ # schema-0 stay clean, so a flat-panel install never pulls it in — which is # what bounds the release coupling this dependency introduces to panels on # r202633+. - "ebus-sdk>=0.19.0,<1.0", + # Ceiling tightened to the versions actually tested. 0.x carries no + # compatibility contract, 0.21 and 0.22 shipped on the same day, and this + # release reaches hosts that are not quick to iterate on. Widening a ceiling + # later is a patch release; narrowing one after a user's host has resolved a + # bad pairing is not. 0.22 was checked module by module and changes only + # publisher-side code -- `adapter.py`, `topology.py`, `transport.py` and + # `property.py` are byte-identical to 0.21 -- so this is a bound on exposure + # rather than a report of breakage. + "ebus-sdk>=0.19.0,<0.23", ] [project.urls] diff --git a/pyproject.toml b/pyproject.toml index 6b840c4..f82895a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,18 +10,34 @@ license = "MIT" license-files = ["LICENSE"] requires-python = ">=3.10,<4.0" dependencies = [ - "httpx>=0.28.1", + # Bounded, and the bound is load-bearing. httpx 1.0 is an API rewrite that + # removes `AsyncClient` -- 1.0.dev1..dev4 are on PyPI now, and every + # distribution of this library is a prerelease, so `pip install --pre`, the + # verb RELEASE.md itself prescribes, resolves them. `paho-mqtt` has been + # bounded from the start; this was the one unbounded runtime dependency, and + # a ceiling cannot be added to a version already published. + "httpx>=0.28.1,<1.0", "paho-mqtt>=2.0.0,<3.0.0", "pyyaml>=6.0.0", ] +[project.optional-dependencies] +# Not runtime dependencies: this distribution still registers no adapter and +# imports none, and `scripts/verify_adapterless_install.py` holds that line. +# These exist so `pip install -U --pre "span-panel-api[schema-0,schema-1]"` has a +# correct upgrade path, because the dependency arrow runs the other way -- an +# adapter floors on the bootstrap, the bootstrap requires no adapter -- so +# upgrading the bootstrap alone leaves stale adapter wheels that +# `_derive_required_members` then rejects at discovery, with pip reporting +# success. An extra is the only thing pip can act on, and extras cannot be added +# to a version after it is published. +schema-0 = ["span-panel-api-schema-0>=1.0.0b5"] +schema-1 = ["span-panel-api-schema-1>=0.1.0b6"] + [project.urls] Homepage = "https://github.com/SpanPanel/span-panel-api" Issues = "https://github.com/SpanPanel/span-panel-api/issues" -[project.scripts] -format-markdown = "scripts.format_markdown:main" - # No [project.entry-points."span_panel_api.schema_adapters"] block here, and that # absence is the point of Phase 1: this distribution registers no adapter and # imports none. Adapters are separate distributions that register themselves — @@ -72,7 +88,29 @@ span-panel-api-schema-0 = { workspace = true } span-panel-api-schema-1 = { workspace = true } [tool.hatch.build.targets.wheel] -packages = ["src/span_panel_api", "scripts"] +# `scripts/` is deliberately absent. Shipping it put `scripts/__init__.py` at the +# top level of every consumer's site-packages, so an unrelated `import scripts` +# in a Home Assistant venv resolved to this distribution, and it installed a +# markdown formatter as a console script for every user. It is a dev tool; +# `scripts/format.sh` runs it by path. +packages = ["src/span_panel_api"] + +[tool.hatch.build.targets.sdist] +# Explicit, because the default swept the whole tree: the root sdist contained +# packages/schema-0 and packages/schema-1 in full, contradicting the one +# invariant this distribution is built around -- that it registers no adapter and +# imports none. Anyone auditing the bootstrap sdist found both parsers inside it. +# Anchored with a leading slash: an unanchored "README.md" is a glob that matches +# at any depth, which pulled each adapter's own README, CHANGELOG and pyproject +# back in and left the bootstrap sdist still naming both parsers. +include = [ + "/src/span_panel_api", + "/tests", + "/README.md", + "/CHANGELOG.md", + "/LICENSE", + "/pyproject.toml", +] [tool.ruff] line-length = 125 diff --git a/scripts/format.sh b/scripts/format.sh index 0fd6c1d..dc46bbe 100755 --- a/scripts/format.sh +++ b/scripts/format.sh @@ -20,6 +20,6 @@ uv run ruff check src/ \ --exclude=src/span_panel_api/generated_client/** # Format markdown files -uv run format-markdown +uv run python scripts/format_markdown.py echo "✅ Formatting complete!" diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 8951d2a..ba12da7 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -846,9 +846,23 @@ async def _redispatch_if_generation_changed(self) -> None: data, because the two schemas do not share a topic shape. """ try: - schema = await self._fetch_schema_with_retry() + await self._redispatch_once() finally: + # Released only when the swap is finished, not when the fetch is. + # Clearing it after the fetch left a window that the slowest step in + # the method sits inside: `_preload_adapter` imports the new parser in + # a thread and takes seconds on a cold schema_1 import, and through + # all of it `_data_model_version` still holds the old value, so + # `_generation_appears_changed()` was still true. A second retained + # `data-model-version` message -- or the connect edge -- scheduled a + # second redispatch, and the consumer got two schema-change callbacks + # for one upgrade. The integration reloads its config entry off that + # callback, so that is a reload racing its own teardown. self._redispatch_in_flight = False + + async def _redispatch_once(self) -> None: + """The body of one redispatch. See `_redispatch_if_generation_changed`.""" + schema = await self._fetch_schema_with_retry() if schema is None: return diff --git a/tests/test_adopted_control.py b/tests/test_adopted_control.py index 0e8db48..caccd4e 100644 --- a/tests/test_adopted_control.py +++ b/tests/test_adopted_control.py @@ -118,3 +118,71 @@ async def test_a_device_that_has_left_the_tree_stops_being_writable() -> None: await client.set_adopted_property(DEVICE, "generator", "mode", "OFF") bridge.publish.assert_not_called() + + +def _two_generators() -> tuple[SpanMqttClient, MagicMock]: + """Two adopted devices of one unmodelled type, each declaring the same control. + + The realistic shape, and the one the single-device fixtures above cannot + exercise: nothing about adoption limits a panel to one generator, and two of a + kind is exactly when a device id stops being decoration. + """ + config = MqttClientConfig(broker_host="h", username="u", password="p") + client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + + def control(device_id: str) -> AdoptedProperty: + return AdoptedProperty( + node_id="generator", + property_id="mode", + datatype="enum", + format="AUTO,MANUAL,OFF", + settable=True, + value="AUTO", + set_topic=f"ebus/5/{device_id}/generator/mode/set", + ) + + adapter = MagicMock() + adapter.build_snapshot.return_value = MagicMock( + adopted_devices=tuple( + AdoptedDevice( + device_id=device_id, + device_type="energy.ebus.device.generator", + properties=(control(device_id),), + ) + for device_id in ("generator-1", "generator-2") + ) + ) + client._adapter = adapter + bridge = MagicMock() + client._bridge = bridge + return client, bridge + + +@pytest.mark.asyncio +async def test_the_write_reaches_the_device_that_was_named() -> None: + """The device id is the authorization, not a label on it. + + The lookup returns the first device carrying the node and property asked for, + so without the id filter a write aimed at the second generator publishes to + the first one's topic -- the panel accepts it, and the wrong machine changes + mode. Every other test here uses a single adopted device, where the filter + cannot be wrong because there is nothing else to match. + """ + client, bridge = _two_generators() + + await client.set_adopted_property("generator-2", "generator", "mode", "MANUAL") + + topic, payload = bridge.publish.call_args[0][:2] + assert topic == "ebus/5/generator-2/generator/mode/set" + assert payload == "MANUAL" + + +@pytest.mark.asyncio +async def test_a_device_that_is_not_adopted_is_refused_even_when_a_sibling_declares_the_property() -> None: + """The property existing somewhere is not the property existing here.""" + client, bridge = _two_generators() + + with pytest.raises(SpanPanelServerError): + await client.set_adopted_property("generator-3", "generator", "mode", "MANUAL") + + bridge.publish.assert_not_called() diff --git a/uv.lock b/uv.lock index 69433dc..ffb99de 100644 --- a/uv.lock +++ b/uv.lock @@ -1331,6 +1331,14 @@ dependencies = [ { name = "pyyaml" }, ] +[package.optional-dependencies] +schema-0 = [ + { name = "span-panel-api-schema-0" }, +] +schema-1 = [ + { name = "span-panel-api-schema-1" }, +] + [package.dev-dependencies] dev = [ { name = "bandit" }, @@ -1353,10 +1361,13 @@ dev = [ [package.metadata] requires-dist = [ - { name = "httpx", specifier = ">=0.28.1" }, + { name = "httpx", specifier = ">=0.28.1,<1.0" }, { name = "paho-mqtt", specifier = ">=2.0.0,<3.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, + { name = "span-panel-api-schema-0", marker = "extra == 'schema-0'", editable = "packages/schema-0" }, + { name = "span-panel-api-schema-1", marker = "extra == 'schema-1'", editable = "packages/schema-1" }, ] +provides-extras = ["schema-0", "schema-1"] [package.metadata.requires-dev] dev = [ @@ -1400,7 +1411,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "ebus-sdk", specifier = ">=0.19.0,<1.0" }, + { name = "ebus-sdk", specifier = ">=0.19.0,<0.23" }, { name = "span-panel-api", editable = "." }, ] From bc2e79101da57d0d6e03aca7f3644a24ab4ede97 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:01:22 -0700 Subject: [PATCH 101/115] feat(models): say whether the upstream lugs are the utility connection point `SpanPanelSnapshot.lugs_at_service_entrance`. `instant_grid_power_w` is the upstream lugs' `meter/active-power`, and the name holds only where those lugs are the service entrance. Two ordinary topologies break it: a BESS wired ahead of the main lugs, and an enclosure fed by another enclosure. In both the lugs meter panel-side flow while the utility side differs by whatever the intervening device contributes or absorbs, so `instant_grid_power_w` and `power_flow_grid` legitimately disagree -- and before this a consumer seeing them disagree had no way to tell a topology from a fault. Sourced from the lugs' own `connection/fed-by-device-id`, which the specification names as the detection mechanism: `power-flows` 0.3 qualified its negation table to say the `grid` row holds "only where the lugs are the utility connection point" and pointed consumers here. This parser already read that property and then discarded it -- it fed relative position and nothing else -- so the fact existed nowhere a consumer could reach. The reference capture turns out to be one of these topologies. Its upstream lugs publish `fed-by-device-id: bess`, so the reference panel reports `False`: the producer wires the battery ahead of the main lugs and computes `power-flows/grid` from the lugs together with the BESS rather than by negating the lugs. That makes the capture falsifiable in both directions without being contrived, and it is pinned as its own test, because a reference capture is usually the simple case and this one is not. A boolean rather than the intervening device's id: what a consumer needs is whether to trust the lugs as grid. Naming the device would invite a second, weaker inference about what is upstream, which the enclosure-chain case cannot support at all -- the feeding device is another panel with its own tree, not a child of this one. Defaults `True`, which is a fact rather than an optimism. Flat firmware predates chaining and publishes no way to express it, so a flat panel's lugs are its service entrance; schema_0 leaves the field alone. Pinned by a test, because mutating the default alone left the whole suite green. Additive, so it costs neither a `SchemaAdapter` member nor an `ADAPTER_CONTRACT_VERSION` bump, and appended rather than inserted so positional construction does not shift. Two comments and the README asserted the pre-0.3 rule flatly -- "upstream lugs are the grid connection" -- and are corrected. The four `connection` property names move to `const`, because `panel` now reads one and `devices` imports `panel`. --- CHANGELOG.md | 6 + README.md | 10 +- .../src/span_panel_api_schema_1/const.py | 8 + .../src/span_panel_api_schema_1/devices.py | 9 +- .../src/span_panel_api_schema_1/panel.py | 33 +++- .../src/span_panel_api_schema_1/snapshot.py | 1 + src/span_panel_api/models.py | 44 +++++ tests/test_schema_one_service_entrance.py | 163 ++++++++++++++++++ 8 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 tests/test_schema_one_service_entrance.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e755d9a..546a570 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- **`SpanPanelSnapshot.lugs_at_service_entrance`, saying whether this enclosure's upstream lugs are the utility connection point.** `instant_grid_power_w` is those lugs' `meter/active-power`, and the name holds only at the service entrance: a BESS wired + ahead of the main lugs, or an enclosure fed by another enclosure, leaves the lugs metering panel-side flow while the utility side differs by whatever that device contributes or absorbs. `power_flow_grid` stays site-level and correct in both, so the two + legitimately disagree — and before this a consumer seeing them disagree could not tell a topology from a fault. Sourced from the lugs' `connection/fed-by-device-id`, which `power-flows` 0.3 names as the detection mechanism when it qualifies its own + negation table; this library already read that property and then discarded it, so no consumer could compute this for itself. Defaults `True` because flat firmware predates chaining and a flat panel's lugs really are its service entrance, so schema_0 + leaves it alone. Additive, so it costs no protocol member and no contract bump. Worth knowing: the reference capture publishes `fed-by-device-id: bess` on its upstream lugs, so the reference panel reports `False`. + - **An adopted device carries the proxy link it declares: `AdoptedDevice.parent` and `AdoptedDevice.proxied`.** Carried rather than acted on — an adopted device is still registered under the enclosure — because a _proxied_ unmodelled device is a real shape that would otherwise be flattened away unrecorded. The reference tree already contains one: `bess-mid` declares `parent: bess`, the `{proxier-id}-{proxied-id}` naming of `devices/proxy.md`. `proxied` is derived against the tree `root` in the adapter, because device ids are opaque and a consumer holding one device cannot tell the enclosure's id from a sibling's. diff --git a/README.md b/README.md index 9ceb8e9..b6e8db2 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,15 @@ async def main(): # Get a point-in-time snapshot snapshot = await client.get_snapshot() - print(f"Grid power: {snapshot.instant_grid_power_w}W") + # The upstream lugs' own meter. That is grid flow only where the lugs are + # the utility connection point; a BESS wired ahead of them, or a panel fed + # by another panel, makes it this panel's feed instead. `power_flow_grid` + # is the site-level figure in every topology. + if snapshot.lugs_at_service_entrance: + print(f"Grid power: {snapshot.instant_grid_power_w}W") + else: + print(f"Panel feed: {snapshot.instant_grid_power_w}W") + print(f"Grid power: {snapshot.power_flow_grid}W") print(f"Firmware: {snapshot.firmware_version}") print(f"Circuits: {len(snapshot.circuits)}") diff --git a/packages/schema-1/src/span_panel_api_schema_1/const.py b/packages/schema-1/src/span_panel_api_schema_1/const.py index 9b468c1..4a135aa 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/const.py +++ b/packages/schema-1/src/span_panel_api_schema_1/const.py @@ -35,6 +35,14 @@ NODE_BREAKER = "breaker" NODE_CONNECTION = "connection" + +# The `connection` node's four property names. Here rather than in `devices`, +# which is where they started, because `panel` reads one of them and `devices` +# imports `panel` -- so the constant has to live below both of them. +PROP_FEEDS_DEVICE_ID = "feeds-device-id" +PROP_FEEDS_DEVICE_STATUS = "feeds-device-status" +PROP_FED_BY_DEVICE_ID = "fed-by-device-id" +PROP_FED_BY_DEVICE_STATUS = "fed-by-device-status" NODE_DOOR = "door" NODE_GRID = "grid" NODE_INFO = "info" diff --git a/packages/schema-1/src/span_panel_api_schema_1/devices.py b/packages/schema-1/src/span_panel_api_schema_1/devices.py index 61bf054..f0093bd 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/devices.py +++ b/packages/schema-1/src/span_panel_api_schema_1/devices.py @@ -67,6 +67,10 @@ NODE_SWITCH, PROP_ACTIVE_POWER, PROP_COMMUNICATION_STATE, + PROP_FED_BY_DEVICE_ID, + PROP_FED_BY_DEVICE_STATUS, + PROP_FEEDS_DEVICE_ID, + PROP_FEEDS_DEVICE_STATUS, PROP_FIRMWARE_VERSION, PROP_HARDWARE_VERSION, PROP_MODEL, @@ -97,11 +101,6 @@ PROP_LOCK_STATE = "lock-state" PROP_STATUS = "status" -PROP_FEEDS_DEVICE_ID = "feeds-device-id" -PROP_FEEDS_DEVICE_STATUS = "feeds-device-status" -PROP_FED_BY_DEVICE_ID = "fed-by-device-id" -PROP_FED_BY_DEVICE_STATUS = "fed-by-device-status" - STATUS_OK = "OK" diff --git a/packages/schema-1/src/span_panel_api_schema_1/panel.py b/packages/schema-1/src/span_panel_api_schema_1/panel.py index bdf9a50..5b81d0f 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/panel.py +++ b/packages/schema-1/src/span_panel_api_schema_1/panel.py @@ -1,10 +1,19 @@ """Map the v1.0 device tree onto the panel-level fields of ``SpanPanelSnapshot``. Where the flat schema kept everything on one device's nodes, v1.0 spreads the -same information across the panel and its children: the grid connection is the +same information across the panel and its children: the service connection is the upstream lugs device, feedthrough is the downstream lugs device, and grid state lives on the MID. +**The upstream lugs are not always the utility connection point.** A BESS wired +ahead of the main lugs, or an enclosure fed by another enclosure, sits between +the utility and this meter, so the lugs read panel-side flow while the grid +differs by whatever that device contributes or absorbs. `power-flows` 0.3 +qualified its own negation table to say so and named the detection mechanism, and +`lugs_at_service_entrance` carries the answer to the snapshot -- without it a +consumer sees `instant_grid_power_w` and `power_flow_grid` disagree and cannot +tell a topology from a fault. + **Direction is per-device, and the two rules are opposites.** Everything is stated in the enclosure's reference frame — power flowing *into* the panel is positive — so: @@ -30,6 +39,7 @@ from span_panel_api_schema_1.const import ( CLOUD_CONNECTED, NODE_BREAKER, + NODE_CONNECTION, NODE_DOOR, NODE_GRID, NODE_GRID_FORMING, @@ -54,6 +64,7 @@ PROP_ENABLED, PROP_ETHERNET, PROP_EXPORTED_ENERGY, + PROP_FED_BY_DEVICE_ID, PROP_FIRMWARE_VERSION, PROP_FULL_CHARGE_TIME_TO_PRIORITY_SHED, PROP_FULL_CHARGE_TOTAL_TIME_REMAINING, @@ -416,9 +427,23 @@ def __init__( self.power_flow_grid = number(panel, NODE_POWER_FLOWS, "grid") self.power_flow_site = number(panel, NODE_POWER_FLOWS, "site") - # Upstream lugs are the grid connection. No sign flip: the enclosure - # frame already reports import-positive, which is what consumption - # means here. + # Whether the upstream lugs are the utility connection point, which is not + # a given: a BESS wired ahead of the main lugs, or a panel fed by another + # panel, puts a device between the utility and this meter. Read from the + # lugs' own `connection/fed-by-device-id`, the mechanism `power-flows` 0.3 + # names when it qualifies the `grid` row of its negation table. Empty + # string is the absence -- `text` defaults to it -- and absence is the + # ordinary case. + self.lugs_at_service_entrance = not text(upstream_lugs, NODE_CONNECTION, PROP_FED_BY_DEVICE_ID) + + # No sign flip: the enclosure frame already reports import-positive, which + # is what consumption means here. + # + # The name says grid, and that is only true when the lugs are the service + # entrance. Where they are not, this is the panel's own feed and + # `power_flow_grid` is the site-level figure; `lugs_at_service_entrance` + # above is how a consumer tells the two apart. The reading itself is + # correct in either topology -- it is the label that is conditional. self.instant_grid_power_w = number(upstream_lugs, NODE_METER, PROP_ACTIVE_POWER) or 0.0 self.main_meter_energy_consumed_wh = number(upstream_lugs, NODE_METER, PROP_IMPORTED_ENERGY) or 0.0 self.main_meter_energy_produced_wh = number(upstream_lugs, NODE_METER, PROP_EXPORTED_ENERGY) or 0.0 diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index 4d6b83b..bc006e8 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -143,6 +143,7 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re firmware_version=fields.firmware_version, main_relay_state=fields.main_relay_state, instant_grid_power_w=fields.instant_grid_power_w, + lugs_at_service_entrance=fields.lugs_at_service_entrance, feedthrough_power_w=fields.feedthrough_power_w, main_meter_energy_consumed_wh=fields.main_meter_energy_consumed_wh, main_meter_energy_produced_wh=fields.main_meter_energy_produced_wh, diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index c96eee4..e7ec62a 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -947,3 +947,47 @@ class SpanPanelSnapshot: holding zeros are different facts, and only a nullable member can tell them apart — every limit in this capture is a legal `0.0`. """ + + lugs_at_service_entrance: bool = True + """Whether this enclosure's upstream lugs *are* the utility connection point. + + `False` means something sits between the utility and the main lugs, so the + lugs measure flow on the panel side of that device while the utility side + differs by whatever it contributes or absorbs. Two ordinary topologies do + this: an **upstream DER**, a BESS wired ahead of the main lugs, and an + **enclosure chain**, where this panel is fed by another panel rather than by + the service. + + **What it is for.** `instant_grid_power_w` is the upstream lugs' + `meter/active-power`. On a panel at the service entrance that reading *is* + grid flow, which is why the field carries that name. On a panel where this is + `False` it is the panel's own feed, and presenting it as grid power is wrong + -- `power_flow_grid` is then the only site-level figure. The two will + legitimately disagree, and without this a consumer seeing them disagree has + no way to tell a topology from a fault. + + Sourced from the lugs device's `connection/fed-by-device-id`, which the + specification names as the detection mechanism: `power-flows` 0.3 qualified + its own negation table to say the `grid` row holds "only where the lugs are + the utility connection point", and pointed consumers here. The property is + read by this library already; before this field it was consumed for relative + position and otherwise discarded, so no consumer could compute this for + itself. + + Defaults `True`, and the default is a fact rather than an optimism: flat + firmware predates enclosure chaining and publishes no way to express it, so a + flat panel's lugs are its service entrance. schema_0 leaves it alone for that + reason. + + A defaulted snapshot field rather than a `SchemaAdapter` member, for the + reason `adopted_devices` gives above: the protocol derives its required + members from itself, so a new member would be required of every adapter + package and would invalidate built wheels. + + A boolean rather than the intervening device's id, because the id answers a + question nobody downstream asks. What a consumer needs is whether to trust + the lugs as grid; naming the device would invite a second, weaker inference + about *what* is upstream, which the enclosure-chain case cannot support -- + the feeding device is another panel with its own tree, not a child of this + one. + """ diff --git a/tests/test_schema_one_service_entrance.py b/tests/test_schema_one_service_entrance.py new file mode 100644 index 0000000..2a07900 --- /dev/null +++ b/tests/test_schema_one_service_entrance.py @@ -0,0 +1,163 @@ +"""Whether this enclosure's upstream lugs are the utility connection point. + +`instant_grid_power_w` is the upstream lugs' `meter/active-power`, and the name +is only true at the service entrance. Put a BESS ahead of the main lugs, or feed +this panel from another panel, and the lugs measure flow on the panel side of +that device while the utility side differs by whatever it contributes or +absorbs. `power_flow_grid` stays site-level and correct; the two then +legitimately disagree. + +That disagreement is the whole problem. Without a signal a consumer seeing them +differ cannot tell a topology from a fault, and `fed-by-device-id` -- the +mechanism `power-flows` 0.3 names when it qualifies its own negation table -- +was read by this parser and then discarded. So there was nothing downstream +could compute for itself. + +**The reference capture is itself one of these topologies**, which is the part +worth knowing before reading anything below. Its upstream lugs publish +`fed-by-device-id: bess` -- the producer wires the battery ahead of the main +lugs, and computes `power-flows/grid` from the lugs reading together with the +BESS rather than by negating the lugs. So on the reference panel +`instant_grid_power_w` has never been the utility figure, and the flag reads +`False` for it. The capture is falsifiable in both directions without being +contrived, which is why the cases below both republish into it and take it away. +""" + +from __future__ import annotations + +import pytest + +from span_panel_api.models import SpanPanelSnapshot +from span_panel_api_schema_1.const import ( + NODE_CONNECTION, + PROP_FED_BY_DEVICE_ID, + PROP_FED_BY_DEVICE_STATUS, +) +from span_panel_api_schema_1.reference_payloads import ( + RetainedTopicTree, + device_from_topics, + parent_child_tree, +) +from span_panel_api_schema_1.snapshot import build_snapshot + +PANEL = "example-40t-001" +UPSTREAM_LUGS = "lugs-upstream" + +FED_BY_ID_TOPIC = f"{NODE_CONNECTION}/{PROP_FED_BY_DEVICE_ID}" +FED_BY_STATUS_TOPIC = f"{NODE_CONNECTION}/{PROP_FED_BY_DEVICE_STATUS}" + + +def _mutable_tree() -> dict[str, dict[str, str]]: + return {device_id: dict(topics) for device_id, topics in parent_child_tree().items()} + + +def _snapshot(tree: RetainedTopicTree) -> SpanPanelSnapshot: + panel = device_from_topics(PANEL, tree[PANEL]) + children = [device_from_topics(device_id, topics) for device_id, topics in tree.items() if device_id != PANEL] + return build_snapshot(panel, children) + + +def test_the_capture_has_a_battery_ahead_of_its_main_lugs() -> None: + """The reference panel is behind an upstream DER, and reports itself as one. + + Recorded as its own test because it is a claim about the producer rather than + about this parser, and because it is easy to assume the opposite: a reference + capture is usually the simple case, and this one is not. If the producer ever + moves the battery downstream this fails saying so, rather than silently + turning the cases below into assertions about a panel that no longer exists. + """ + tree = _mutable_tree() + assert tree[UPSTREAM_LUGS][FED_BY_ID_TOPIC] == "bess" + assert _snapshot(tree).lugs_at_service_entrance is False + + +def test_a_panel_with_nothing_ahead_of_its_lugs_is_at_the_service_entrance() -> None: + """The ordinary case, reached by taking the capture's upstream BESS away. + + `True` has to be earned from the tree rather than defaulted into: a mapper + that always answered `True` would pass this and fail everything above it, + and one that always answered `False` would do the reverse. + """ + tree = _mutable_tree() + del tree[UPSTREAM_LUGS][FED_BY_ID_TOPIC] + del tree[UPSTREAM_LUGS][FED_BY_STATUS_TOPIC] + + assert _snapshot(tree).lugs_at_service_entrance is True + + +@pytest.mark.parametrize( + ("intervening", "topology"), + [ + ("bess", "a BESS wired between the utility and the main lugs"), + ("example-40t-002", "an enclosure fed by another enclosure"), + ], +) +def test_a_device_between_the_utility_and_the_lugs_is_reported(intervening: str, topology: str) -> None: + """Both topologies the specification names, and one signal covers both. + + They differ in what is upstream and not in what it does to the reading, which + is why this is one boolean rather than a description of the device. The + enclosure-chain case could not carry a description anyway: the feeding device + is another panel with its own tree, not a child of this one. + """ + tree = _mutable_tree() + tree[UPSTREAM_LUGS][FED_BY_ID_TOPIC] = intervening + tree[UPSTREAM_LUGS][FED_BY_STATUS_TOPIC] = "OK" + + assert _snapshot(tree).lugs_at_service_entrance is False, topology + + +def test_an_empty_fed_by_id_is_not_a_device() -> None: + """Homie publishes an empty payload for a property with no value. + + An empty string is the absence, not a device named "". Reading it as one + would tell every panel that publishes the property-but-not-the-value that it + is behind something. + """ + tree = _mutable_tree() + tree[UPSTREAM_LUGS][FED_BY_ID_TOPIC] = "" + del tree[UPSTREAM_LUGS][FED_BY_STATUS_TOPIC] + + assert _snapshot(tree).lugs_at_service_entrance is True + + +def test_the_grid_reading_itself_is_unchanged_either_way() -> None: + """The label is conditional; the measurement is not. + + A panel behind a DER still meters its own lugs correctly, so this must not + become a reason to withhold or alter the value -- only to say what it is. + """ + behind = _mutable_tree() + plain = _mutable_tree() + del plain[UPSTREAM_LUGS][FED_BY_ID_TOPIC] + del plain[UPSTREAM_LUGS][FED_BY_STATUS_TOPIC] + + assert _snapshot(behind).lugs_at_service_entrance != _snapshot(plain).lugs_at_service_entrance + assert _snapshot(behind).instant_grid_power_w == _snapshot(plain).instant_grid_power_w + assert _snapshot(behind).power_flow_grid == _snapshot(plain).power_flow_grid + + +def test_a_panel_with_no_upstream_lugs_is_not_reported_as_behind_something() -> None: + """A tree missing the device says nothing about topology, and `False` is a claim.""" + tree = _mutable_tree() + del tree[UPSTREAM_LUGS] + + assert _snapshot(tree).lugs_at_service_entrance is True + + +def test_a_flat_panel_reports_itself_at_the_service_entrance() -> None: + """The default is a fact about flat firmware, not an optimism about it. + + Flat predates enclosure chaining and publishes no way to express it, so a flat + panel's lugs *are* its service entrance and `True` is the right answer rather + than a safe-looking one. schema_0 therefore leaves the field alone, and this + is what holds the default where it is -- without it the field could be + defaulted either way and every schema-1 test above would still pass. + """ + from conftest import flat_schema + from span_panel_api_schema_0 import SchemaZeroAdapter + + adapter = SchemaZeroAdapter(serial_number="sim-40t-001", schema=flat_schema(40)) + + assert SpanPanelSnapshot.__dataclass_fields__["lugs_at_service_entrance"].default is True + assert adapter.build_snapshot().lugs_at_service_entrance is True From 1f3ac263aa86c453cea46ce4f1104ef5a7f42915 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:13:47 -0700 Subject: [PATCH 102/115] docs(changelog): the battery asymmetry is real; keep it Correcting an over-correction of my own. The original entry was wrong in one clause -- it called `power_w` charge-positive -- and right in the next, that it is deliberately opposite to `panel.power_flow_battery`. Fixing the first, I flipped the second as well and claimed the two agree. They do not. The enclosure's arbitrated figure is passed through untouched by both adapters and is charge-positive: it reads negative for the same discharging battery that makes `power_w` positive. The two are the same physical power in different frames, and a consumer rendering both negates one of them -- which is what the Home Assistant integration does at the entity, landing both of its battery sensors on discharge-positive. No shipped value is affected by any of this, and none ever was. The negation in `build_battery` has always been there and has always been right; only the helper name and these notes ever asserted a direction the code did not hold. --- CHANGELOG.md | 8 +++++--- packages/schema-1/CHANGELOG.md | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 546a570..676feec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,9 +79,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), a field, so a consumer could show the enclosure's arbitrated `power_flow_battery` and nothing the BESS itself reports. Both are `None` on a BESS that publishes no such node, and on every flat panel — the flat schema's BESS device class declares neither property, so this is new surface rather than a re-sourcing, and nothing that exists today changes. - **`power_w` is discharge-positive, and the wire is not.** The enclosure meters the BESS the way it meters a circuit it feeds, so a _discharging_ battery publishes a negative `meter/active-power`; `build_battery` negates it, exactly as `build_circuit` - does for a load. Positive therefore means the battery is supplying power, which is the frame `panel.power_flow_battery` already uses and which the capability catalog defines — the two agree rather than opposing each other, so a consumer rendering both - negates neither. This entry said the opposite until the direction was settled by measurement rather than by reading: with the producer driven into self-consumption and the grid at exactly zero, the wire read `-1917.49` and the snapshot reported - `+1917.49` while the battery was discharging. `_charge_positive` was renamed `_discharge_positive` in the same pass, and the convention now matches `pv_power` positive-while-producing and `grid_power_flow` positive-while-importing. + does for a load. Positive therefore means power flowing _out of_ the battery. This entry said charge-positive until the direction was settled by measurement rather than by reading: with the producer driven into self-consumption and the grid at exactly + zero — PV 4181 W plus battery 1917 W meeting a 6099 W load, so the battery can only be discharging — the snapshot reported `+1917.49`. `_charge_positive` was renamed `_discharge_positive` in the same pass. No published value changed; the negation was + always there and always right, and only the name and this note asserted a direction the code did not hold. +- The asymmetry with `panel.power_flow_battery` is real and unchanged: the enclosure's own arbitrated figure is passed through untouched by both adapters and is charge-positive, so it reads negative for the same discharging battery that makes `power_w` + positive. The two describe the same physical power in opposite frames, and a consumer rendering both negates one of them — which is what the Home Assistant integration does, landing both of its entities on discharge-positive. - **`communication_state` stays the published enum string** (`OK`/`DEGRADED`/`LOST`/`UNKNOWN`) rather than collapsing to a bool: `DEGRADED` is neither `OK` nor `LOST`, and a bool would have to pick one. It is deliberately not merged into `battery.connected`, which is the _enclosure's_ `connection/fed-by-device-status` view of the same link. One is the device speaking about itself and the other the panel speaking about it, and the migration guide warns against conflating them. - **`_PROPERTY_FIELD_MAP` rows for both**, which buys them the unit and datatype the BESS's own `$description` declares plus the three-way resolution contract — a BESS that publishes the node while omitting the property reports degradation rather than diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index a401004..3724a7f 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -16,8 +16,10 @@ only publisher-side code, leaving `adapter.py`, `topology.py`, `transport.py` an - **The BESS meter is discharge-positive, and was named for the opposite.** `_charge_positive` is renamed `_discharge_positive`. No published value changes — the negation was always right — but the name asserted a direction that the wire does not carry, and the root changelog documented that wrong direction as fact. Settled by measurement rather than by reading: with the producer in self-consumption and the grid at exactly zero, `pv −4181.34 + battery −1917.49 + grid −0.0 + site +6098.83 = 0`, so the - battery was discharging at 1917 W and both `battery.power_w` and `bess_meter_power` reported `+1917.49`. The convention therefore matches `pv_power` positive-while-producing and `grid_power_flow` positive-while-importing, and agrees with - `panel.power_flow_battery` rather than opposing it. + battery was discharging at 1917 W and `battery.power_w` reported `+1917.49`. The convention matches the eBus rule for a device's own meter: positive is power flowing out of the device. + + It stays deliberately opposite to `panel.power_flow_battery`, which is the enclosure's arbitrated figure, passed through untouched and charge-positive. The two are the same physical power in different frames, and a consumer rendering both negates one of + them. ### Added From 079debdad754033919421319c9ff4a5fcd15d5e5 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:51:01 -0700 Subject: [PATCH 103/115] test(schema-1): a float property published without a decimal point still parses Live firmware publishes integer literals for `float` properties, and does it inconsistently within a single node: a service-entrance capture arrived with `power-flows/pv` as `-2434`, `battery` as `0` and `grid` as `-310`, while `site` beside them read `2744.0`. All four declare `datatype: float`. An integer literal is a legal float payload under Homie 5, so this is firmware being terse rather than wrong -- but nothing can be inferred from a sample of one property, because the siblings disagree. The shipped parser already handles it; `float()` does not care. The test exists because no producer we develop against does this. The reference emitter publishes a decimal point every time, so a stricter parse would pass the entire suite while silently dropping three of the four site flows to `None` and reporting the panel as having no power-flows node at all. Mutating `number()` to require a decimal point now fails 23 tests instead of none. The capture that turned this up also confirms `lugs_at_service_entrance` against hardware for the first time: that panel's upstream lugs publish `fed-by-device-id` naming its BESS, and running the shipped parser over the capture reports `False`, with the four power-flows terms summing to exactly zero. --- tests/test_schema_one_service_entrance.py | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_schema_one_service_entrance.py b/tests/test_schema_one_service_entrance.py index 2a07900..042413b 100644 --- a/tests/test_schema_one_service_entrance.py +++ b/tests/test_schema_one_service_entrance.py @@ -161,3 +161,36 @@ def test_a_flat_panel_reports_itself_at_the_service_entrance() -> None: assert SpanPanelSnapshot.__dataclass_fields__["lugs_at_service_entrance"].default is True assert adapter.build_snapshot().lugs_at_service_entrance is True + + +def test_a_float_property_published_without_a_decimal_point_still_parses() -> None: + """Live firmware publishes integer literals for `float` properties, inconsistently. + + Observed on a service-entrance panel: `power-flows/pv` arrived as `-2434`, + `battery` as `0` and `grid` as `-310`, while `site` on the same node arrived + as `2744.0`. All four declare `datatype: float`. An integer literal is a legal + float payload under Homie 5, so this is firmware being terse rather than + wrong -- but the inconsistency is between sibling properties of one node, so + nothing can be inferred from a sample of one property. + + Worth its own test because no producer we develop against does it: the + reference emitter publishes a decimal point every time, so the whole suite + would pass while a stricter parse silently dropped three of the four site + flows to `None` and reported the panel as publishing no power-flows node. + """ + tree = _mutable_tree() + for name, terse in (("pv", "-2434"), ("battery", "0"), ("grid", "-310")): + tree[PANEL][f"power-flows/{name}"] = terse + tree[PANEL]["power-flows/site"] = "2744.0" + + snapshot = _snapshot(tree) + + assert snapshot.power_flow_pv == -2434.0 + assert snapshot.power_flow_battery == 0.0 + assert snapshot.power_flow_grid == -310.0 + assert snapshot.power_flow_site == 2744.0 + # The four terms sum to zero, which is the identity the specification states + # and which a dropped term would break silently rather than loudly. + assert ( + snapshot.power_flow_pv + snapshot.power_flow_battery + snapshot.power_flow_grid + snapshot.power_flow_site + ) == 0.0 From 4b6b4d073c8b377c9c873511553fa6899523056e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:08:01 -0700 Subject: [PATCH 104/115] ci: keep the cloned peer repositories out of markdownlint The provenance checks added on this branch clone the eBus specification and panelbench into `peers/`, and markdownlint-cli2 scans the tree through its own `globs` rather than through the files pre-commit hands it -- so being gitignored did not keep them out. 835 findings in upstream's prose failed the job before the test step ran, which is the same failure shape the vendored spec exclusion above already exists to prevent, at whole-repository scale. Upstream's line lengths are not ours to correct, and a run that cannot reach its tests is worse than no run: it looks red for a reason that has nothing to do with what is being released. --- .markdownlint-cli2.jsonc | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 16509c1..dac34f2 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -42,6 +42,13 @@ // `globs` above scans the tree directly, so pre-commit's `exclude` cannot // filter this out -- it has to be ignored here. "packages/schema-1/spec/**", + // The peer checkouts CI clones for the provenance checks: the eBus + // specification and panelbench, cloned into a gitignored `peers/`. Same + // reasoning as the vendored spec above and more so -- these are whole + // upstream repositories, and 835 findings in somebody else's prose were + // enough to fail the job before the tests ran. `globs` scans the tree + // directly, so being gitignored is not enough to keep them out. + "peers/**", ".venv/**", "venv/**", "node_modules/**", From 196e566e47bfaea9b02e7341eb7a6ba59d709e6e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:40:27 -0700 Subject: [PATCH 105/115] feat(client): the runtime path takes the caller's HTTP client `SpanMqttClient` and `create_span_client` accept an optional `httpx_client`. Optional and defaulted, so nothing outside Home Assistant changes. Four config-flow-facing entry points -- `detect_api_version`, `register_v2`, `download_ca_cert`, `get_homie_schema` -- have taken an injected client all along. The runtime path was the one that did not, so every schema read built a throwaway: once at connect, and once per attempt inside `_fetch_schema_with_retry`, which is the loop that runs while a panel is mid-reboot after a firmware upgrade and can go five times in a row. The integration's own `quality_scale.yaml` declares `inject-websession: done`, and that was true of the config flow and of nothing that ran after it. This is completing a pattern that already existed rather than introducing one, which is also why the shape is a constructor argument: passing a pre-fetched `schema=` would sidestep only the connect fetch and leave the retry loop untouched, and a module-level setter or a factory callable would be new indirection for a problem neither solves better. The ownership rule is the one the existing entry points already state, and it is the whole contract: a client handed in is never closed here, and its timeouts, limits and headers are the caller's -- which is why the per-call `timeout` defaults are documented as ignored when a client is injected. Home Assistant's shared client carries httpx's default timeout rather than this library's 10 s. That is a real change and it is the caller exercising policy it owns, not a setting going missing; the retry loop is what absorbs it on the path where patience matters. Verified there is nothing to close: `_get_client` yields an injected client from an early return that the `async with` never wraps, and no `aclose` appears anywhere in the library. A test pins that, because relying on Home Assistant to guard its own client is relying on the caller. No contract impact -- `_derive_required_members` reads the `SchemaAdapter` Protocol, and `SpanMqttClient` is a consumer of adapters rather than part of it, so `ADAPTER_CONTRACT_VERSION` stays at 1 and neither adapter distribution moves. Root only: 3.0.0b6 -> 3.0.0b7. Three mutations verified dead: dropping the stored client, and un-injecting each of the two call sites independently. --- CHANGELOG.md | 9 +++ pyproject.toml | 2 +- src/span_panel_api/factory.py | 16 +++- src/span_panel_api/mqtt/client.py | 19 ++++- tests/test_shared_http_client.py | 121 ++++++++++++++++++++++++++++++ uv.lock | 2 +- 6 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 tests/test_shared_http_client.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 676feec..cecd3e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0b7] + +### Changed + +- **`SpanMqttClient` accepts an `httpx_client`, and so does `create_span_client`.** Four config-flow-facing entry points already took an injected client; the runtime path was the one that did not, so every schema read built a throwaway — including the + retry loop that runs during a firmware upgrade, which built one per attempt at exactly the moment the panel was mid-reboot. Optional and defaulted, so nothing outside Home Assistant changes. The ownership rule is the one the existing entry points already + state: a client handed in is never closed here, and its timeouts, limits and headers are the caller's, which is why the per-call `timeout` defaults are ignored when one is given. Home Assistant's shared client carries httpx's default timeout rather than + this library's 10 s, and that is the caller exercising the policy it owns rather than a setting being lost. + ## [3.0.0b6] ### Added diff --git a/pyproject.toml b/pyproject.toml index f82895a..00ef5b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "3.0.0b6" +version = "3.0.0b7" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} diff --git a/src/span_panel_api/factory.py b/src/span_panel_api/factory.py index 3e3e5d1..246864e 100644 --- a/src/span_panel_api/factory.py +++ b/src/span_panel_api/factory.py @@ -8,6 +8,7 @@ import asyncio import logging +from typing import TYPE_CHECKING from .adapters import resolve_adapter from .auth import get_homie_schema, register_v2 @@ -17,6 +18,9 @@ from .mqtt.client import SpanMqttClient from .mqtt.models import MqttClientConfig +if TYPE_CHECKING: + import httpx + _LOGGER = logging.getLogger(__name__) _V2_CLIENT_NAME = "span-panel-api" @@ -28,6 +32,7 @@ async def create_span_client( mqtt_config: MqttClientConfig | None = None, serial_number: str | None = None, port: int = 80, + httpx_client: httpx.AsyncClient | None = None, ) -> SpanMqttClient: """Create a SPAN Panel MQTT client. @@ -37,6 +42,10 @@ async def create_span_client( mqtt_config: Pre-built MQTT broker configuration. serial_number: Panel serial number (extracted from detection/registration if omitted). port: HTTP port of the panel bootstrap API used for registration and detection. + httpx_client: Optional shared ``httpx.AsyncClient``, used for every request this + makes and handed to the client it builds. Not closed here; its timeouts and + limits are the caller's, which is why the per-call ``timeout`` defaults are + ignored when one is given. Returns: A connected-ready SpanMqttClient instance. @@ -54,7 +63,7 @@ async def create_span_client( if mqtt_config is None: if passphrase is None: raise SpanPanelAuthError("Neither mqtt_config nor passphrase provided") - auth_response = await register_v2(host, _V2_CLIENT_NAME, passphrase, port=port) + auth_response = await register_v2(host, _V2_CLIENT_NAME, passphrase, port=port, httpx_client=httpx_client) mqtt_config = MqttClientConfig( broker_host=auth_response.ebus_broker_host, username=auth_response.ebus_broker_username, @@ -68,7 +77,7 @@ async def create_span_client( if serial_number is None: # Try to detect from panel status - result = await detect_api_version(host, port=port) + result = await detect_api_version(host, port=port, httpx_client=httpx_client) if result.status_info is not None: serial_number = result.status_info.serial_number @@ -80,7 +89,7 @@ async def create_span_client( # flat-versus-parent/child signal, mirroring MQTT's `info/data-model-version` # — so the parser is chosen before a single message is consumed, rather than # a wrong parser being discovered by its output. - schema = await get_homie_schema(host, port=port) + schema = await get_homie_schema(host, port=port, httpx_client=httpx_client) adapter_key, dispatch_reason = select_adapter_key(schema.data_model_version) # In a thread: resolution reads distribution metadata and imports the adapter # package, and this is the first call in the process to do either. See @@ -96,6 +105,7 @@ async def create_span_client( data_model_version=schema.data_model_version, schema_dispatch_reason=dispatch_reason, schema=schema, + httpx_client=httpx_client, ) await client.connect() return client diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index ba12da7..e1e4ff2 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -13,6 +13,7 @@ from importlib.metadata import version import logging import time +from typing import TYPE_CHECKING from span_panel_api.schema_drift import log_schema_drift @@ -34,6 +35,9 @@ from .const import MQTT_READY_TIMEOUT_S from .models import MqttClientConfig +if TYPE_CHECKING: + import httpx + _LOGGER = logging.getLogger(__name__) # How long to wait for circuit name properties after device ready. @@ -74,6 +78,7 @@ def __init__( data_model_version: str | None = None, schema_dispatch_reason: str | None = None, schema: V2HomieSchema | None = None, + httpx_client: httpx.AsyncClient | None = None, ) -> None: self._host = host self._serial_number = serial_number @@ -81,6 +86,12 @@ def __init__( self._snapshot_interval = snapshot_interval self._panel_http_port = panel_http_port self._adapter_factory = adapter_factory + # Shared by the caller, owned by the caller: never closed here, and its + # policy -- timeouts, limits, headers -- is whatever the caller set. That + # is the same rule the four config-flow entry points already state, and + # the reason this exists at all is that the runtime path was the one place + # left without it. See `_get_client`. + self._httpx_client = httpx_client self._bridge: AsyncMqttBridge | None = None self._adapter: SchemaAdapter | None = None @@ -287,7 +298,11 @@ async def connect(self) -> None: # would be a second call to the same unauthenticated endpoint for a # value that cannot have changed. A directly-constructed client has no # schema yet, so it fetches here and dispatches in _build_adapter. - schema = self._schema if self._schema is not None else await get_homie_schema(self._host, port=self._panel_http_port) + schema = ( + self._schema + if self._schema is not None + else await get_homie_schema(self._host, port=self._panel_http_port, httpx_client=self._httpx_client) + ) self._schema = schema await self._preload_adapter(schema) adapter = self._build_adapter(schema) @@ -807,7 +822,7 @@ async def _fetch_schema_with_retry(self) -> V2HomieSchema | None: last: Exception | None = None for _ in range(_REDISPATCH_RETRY_ATTEMPTS): try: - return await get_homie_schema(self._host, port=self._panel_http_port) + return await get_homie_schema(self._host, port=self._panel_http_port, httpx_client=self._httpx_client) except (SpanPanelConnectionError, SpanPanelTimeoutError) as exc: last = exc await asyncio.sleep(delay) diff --git a/tests/test_shared_http_client.py b/tests/test_shared_http_client.py new file mode 100644 index 0000000..f9e0bf4 --- /dev/null +++ b/tests/test_shared_http_client.py @@ -0,0 +1,121 @@ +"""The runtime path uses the caller's HTTP client, not one of its own. + +Four config-flow-facing entry points already take an injected +`httpx.AsyncClient`; `SpanMqttClient` was the one runtime entry point without +it, so every schema fetch built a throwaway client -- including the retry loop +that runs during a firmware upgrade, which builds one per attempt at exactly the +moment the panel is mid-reboot. + +Home Assistant is the caller that cares. It owns a shared client, closes it at +shutdown, and the integration's own `quality_scale.yaml` claims +`inject-websession: done` -- a claim that was true of the config flow and not of +anything that ran afterwards. + +**Ownership is the whole contract.** A client handed in is never closed here, and +its policy is the caller's: timeouts, limits and headers are whatever the caller +configured, which is why the per-call `timeout` arguments are documented as +ignored when a client is injected. Home Assistant's shared client carries +httpx's default timeout rather than this library's, and that is the caller +exercising the policy it owns rather than a setting being lost. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from span_panel_api.mqtt import MqttClientConfig +from span_panel_api.mqtt.client import SpanMqttClient + +SERIAL = "sp3-242424-001" + + +class _Schema: + def __init__(self, version: str | None) -> None: + self.data_model_version = version + + +def _client(injected: object | None) -> SpanMqttClient: + return SpanMqttClient( + host="192.168.1.1", + serial_number=SERIAL, + broker_config=MqttClientConfig(broker_host="broker.local", username="u", password="p"), + httpx_client=injected, # type: ignore[arg-type] + ) + + +@pytest.mark.asyncio +async def test_the_connect_fetch_uses_the_injected_client() -> None: + """The first schema read of a session, and the one every install makes.""" + sentinel = MagicMock(name="shared-client") + client = _client(sentinel) + + fetch = AsyncMock(return_value=_Schema("1.0")) + with ( + patch("span_panel_api.mqtt.client.get_homie_schema", fetch), + patch.object(client, "_preload_adapter", AsyncMock()), + patch.object(client, "_build_adapter", MagicMock()), + patch.object(client, "_connect_bridge", AsyncMock(), create=True), + ): + # Connect goes on to bring up the MQTT bridge, which has nothing to do + # with this assertion and no broker to reach. The fetch is what is under + # test, and the await-count assertion below is what keeps that from + # passing vacuously if it never happened at all. + with contextlib.suppress(Exception): + await client.connect() + + assert fetch.await_count >= 1 + assert fetch.await_args.kwargs["httpx_client"] is sentinel + + +@pytest.mark.asyncio +async def test_the_upgrade_refetch_uses_the_injected_client() -> None: + """The path that mattered most, because it builds one client per retry attempt. + + A panel accepts MQTT before it serves HTTP, so this loop can run several times + in a row while the panel finishes booting -- each one previously a fresh + client, a fresh connection pool, thrown away on the next attempt. + """ + sentinel = MagicMock(name="shared-client") + client = _client(sentinel) + client._loop = asyncio.get_running_loop() + + fetch = AsyncMock(return_value=_Schema("1.0")) + with patch("span_panel_api.mqtt.client.get_homie_schema", fetch): + assert await client._fetch_schema_with_retry() is not None + + assert fetch.await_args.kwargs["httpx_client"] is sentinel + + +@pytest.mark.asyncio +async def test_an_injected_client_is_never_closed_here() -> None: + """It belongs to the caller, and the caller may still be using it. + + Home Assistant hands out one shared client to every integration and closes it + at shutdown; closing it from here would take the others down with it. HA + guards its own copy, but a library that relies on the caller guarding it is + relying on the caller. + """ + sentinel = MagicMock(name="shared-client") + sentinel.aclose = AsyncMock() + client = _client(sentinel) + + await client.close() + + sentinel.aclose.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_no_injected_client_still_works() -> None: + """The default has to stay the default: this library is not Home Assistant's alone.""" + client = _client(None) + client._loop = asyncio.get_running_loop() + + fetch = AsyncMock(return_value=_Schema("1.0")) + with patch("span_panel_api.mqtt.client.get_homie_schema", fetch): + assert await client._fetch_schema_with_retry() is not None + + assert fetch.await_args.kwargs["httpx_client"] is None diff --git a/uv.lock b/uv.lock index ffb99de..032ddab 100644 --- a/uv.lock +++ b/uv.lock @@ -1323,7 +1323,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "3.0.0b6" +version = "3.0.0b7" source = { editable = "." } dependencies = [ { name = "httpx" }, From 27b76713ec3ac9e41ac7b3c5e61b327831c341bd Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:46:29 -0700 Subject: [PATCH 106/115] test(conformance): compare vendored catalogs at the pinned commit, not the clone The docstring said "byte comparison against the specification at `synced_commit`" and the code read the checkout's working tree. `synced_commit` appeared only in the failure message, so the check measured whatever that clone happened to be sitting on. Wrong three ways, one of them dangerous: * it FAILS when the clone has moved ahead of the pin -- ordinary currency drift, not a defect here. Observed the morning the specification went to `power-flows` 0.3, for a change that altered nothing we vendor but a version string. * it FAILS SPURIOUSLY with the clone on an unrelated branch. * it PASSES FALSELY with a clone itself stale at the pinned commit while the specification has moved on -- the case where this check is the only thing that would have told you. Now read out of git at `synced_commit`, so the answer does not depend on the clone's state. A clone that cannot resolve the pin fails with a fetch instruction rather than skipping, because a silent skip reads exactly like a pass on the one check that proves the vendored bytes are what the lockfile claims. The subprocess strips `GIT_*` from its environment, and that is load-bearing rather than tidy: `git -C ` does not beat an exported `GIT_DIR`, and git hooks export one pointing at the repository being committed to. Without the strip this read *our* object store, failed to find a specification commit there, and demanded a fetch for a commit the clone already had -- under pre-commit only, which is where it was caught. Integrity, deliberately not currency. Whether upstream has moved past our pin is a separate question whose answer is normally "yes, a little", and it must not fail a build. The upstream reference producer found and fixed the same defect in its own copy of this check (distribution-enclosure-simulator #47, merged today); the framing here follows it. Verified by mutation: tampering with a vendored catalog fails and names the file; moving the pin to a commit we did not vendor from fails. Passes with the clone at the pin, at a commit ahead of it, and under a simulated hook environment. --- tests/test_schema_one_conformance.py | 63 ++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/tests/test_schema_one_conformance.py b/tests/test_schema_one_conformance.py index 41e1c58..03d4dd3 100644 --- a/tests/test_schema_one_conformance.py +++ b/tests/test_schema_one_conformance.py @@ -37,6 +37,7 @@ import importlib import json import os +import subprocess from pathlib import Path import re from typing import NoReturn @@ -572,26 +573,64 @@ def test_nothing_is_recorded_as_unexercised_once_the_simulator_publishes_it() -> def test_vendored_catalogs_are_byte_identical_to_the_specification() -> None: - """Byte comparison against the specification at `synced_commit`. - - Skipped rather than failed without a checkout: the checks above are the ones - that must run everywhere, and making them depend on a second repository would - mean they stop running. + """Are the bytes we vendored the bytes we claim they are? + + Read out of git **at `synced_commit`** rather than from the checkout's working + tree, so the answer does not depend on what that clone happens to be sitting + on. This used to read the working tree while its own docstring claimed + otherwise, and `synced_commit` appeared only in the failure message. That is + wrong three ways, and one of them is the dangerous one: + + * it **fails** when the clone has moved *ahead* of the pin, which is ordinary + currency drift and not a defect here -- observed the day the specification + went to `power-flows` 0.3; + * it **fails spuriously** with the clone on an unrelated branch; + * it **passes falsely** with a clone itself stale at the pinned commit while + the specification has moved on. + + **Integrity, deliberately not currency.** Whether upstream has moved past our + pin is a separate question whose answer is normally "yes, a little", and it + must not fail a build. Conflating the two is what made this unreliable. + Currency is not checked by anything automatic here, and wants a scheduled job + rather than a gate. + + The upstream reference producer fixed the same defect in its own copy of this + check (`distribution-enclosure-simulator` #47), which is where the framing + comes from. """ spec = _checkout( "EBUS_SPEC_DIR", "a specification checkout to verify vendored bytes", expect="capabilities", ) - differing = [ - path.name - for path in sorted(_CATALOGS.glob("*.json")) - if (spec / "capabilities" / path.name).read_bytes() != path.read_bytes() - ] + commit = _lock()["synced_commit"] + differing: list[str] = [] + for path in sorted(_CATALOGS.glob("*.json")): + blob = subprocess.run( + ["git", "-C", str(spec), "show", f"{commit}:capabilities/{path.name}"], + capture_output=True, + # Stripped, because `-C` does not beat them. Git hooks export `GIT_DIR` + # and `GIT_INDEX_FILE` pointing at the repository being committed to, + # and an exported `GIT_DIR` wins over directory discovery -- so under + # pre-commit this read the *consumer's* object store, could not find a + # specification commit there, and failed with a fetch instruction for a + # commit the clone already had. Caught by the hook that causes it. + env={k: v for k, v in os.environ.items() if not k.startswith("GIT_")}, + ) + if blob.returncode != 0: + # A clone that cannot resolve the pin fails rather than skipping: a + # silent skip reads exactly like a pass on the one check that proves + # the vendored bytes are what the lockfile says. + pytest.fail( + f"{spec} cannot resolve {commit} (needed to read capabilities/{path.name}). " + f"Fetch it: git -C {spec} fetch origin {commit}" + ) + if blob.stdout != path.read_bytes(): + differing.append(path.name) assert not differing, ( - f"vendored catalogs differ from {spec} (lockfile pins {_lock()['synced_commit']}): {differing}. " - "Check the checkout is at synced_commit before assuming the copies are wrong." + f"vendored catalogs differ from the specification at {commit}: {differing}. " + "These are byte copies, so this is a vendoring defect rather than upstream having moved." ) From 578fcce07cf20d66d26c31e385fbf0ce7c59809b Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:56:23 -0700 Subject: [PATCH 107/115] chore(schema-1): allow ebus-sdk 0.23, and say how that was decided The `<0.23` ceiling set earlier today excluded the current SDK on the day it was set: 0.23.0 and 0.23.1 both shipped within hours of it. A bound that stale on arrival is worth re-deriving rather than defending. Re-checked the same way as 0.22 rather than extrapolated from it. Diffing the 0.22.0 and 0.23.1 wheels module by module, exactly two files differ: `__init__.py`, by the version string alone -- so no export changes -- and `declaration.py`, the declarative builder. This distribution's whole SDK surface is `Controller`, `homie.DiscoveredDevice` and structural conformance to `MqttControllerTransport`; nothing here imports `declaration`, and every match for that word in our source is prose in a docstring. The suite is green against 0.23.1 with no source change. The comment now records the method rather than the conclusion, because the conclusion expires. It also records the cost honestly: this bound buys a release here per SDK minor, and the SDK is currently shipping several a day. --- packages/schema-1/CHANGELOG.md | 10 ++++++++++ packages/schema-1/pyproject.toml | 28 ++++++++++++++++++---------- uv.lock | 4 ++-- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index 3724a7f..9d8848a 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -7,6 +7,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is the parent/child device tree SPAN firmware `r202633+` publishes, identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version number. A release here means this parser changed, never that the panel did. +## [0.1.0b7] - 08/2026 + +Pre-release. Requires `span-panel-api` 3.0.0b4 or newer — unchanged. + +### Changed + +- **The eBus SDK ceiling moves to `<0.24`.** 0.23.0 and 0.23.1 both shipped the same day the previous `<0.23` bound was set, so that bound excluded the current release on its first day. Re-checked rather than extrapolated: diffing the 0.22.0 and 0.23.1 + wheels module by module, exactly two files change — `__init__.py`, by the version string alone, and `declaration.py`, the declarative builder. This distribution's whole SDK surface is `Controller`, `homie.DiscoveredDevice` and structural conformance to + `MqttControllerTransport`, and nothing here imports `declaration`. The suite is green against 0.23.1 with no source change. + ## [0.1.0b6] - 08/2026 Pre-release. Requires `span-panel-api` 3.0.0b4 or newer — unchanged. The eBus SDK ceiling tightens to `<0.23`, the versions actually tested: 0.x carries no compatibility contract, and 0.22 was read module by module before the bound was set — it changes diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index e9cbe98..af91d24 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api-schema-1" -version = "0.1.0b6" +version = "0.1.0b7" description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api" authors = [ {name = "SpanPanel"} @@ -19,15 +19,23 @@ dependencies = [ # schema-0 stay clean, so a flat-panel install never pulls it in — which is # what bounds the release coupling this dependency introduces to panels on # r202633+. - # Ceiling tightened to the versions actually tested. 0.x carries no - # compatibility contract, 0.21 and 0.22 shipped on the same day, and this - # release reaches hosts that are not quick to iterate on. Widening a ceiling - # later is a patch release; narrowing one after a user's host has resolved a - # bad pairing is not. 0.22 was checked module by module and changes only - # publisher-side code -- `adapter.py`, `topology.py`, `transport.py` and - # `property.py` are byte-identical to 0.21 -- so this is a bound on exposure - # rather than a report of breakage. - "ebus-sdk>=0.19.0,<0.23", + # Ceiling set to the versions actually tested, and re-checked rather than + # extrapolated each time it moves. 0.x carries no compatibility contract, and + # this ships to hosts that are not quick to iterate on: widening later is a + # patch release, while narrowing after a user's host has resolved a bad + # pairing is not. + # + # Every bump so far has been publisher-side. Diffing the 0.22.0 and 0.23.1 + # wheels module by module, exactly two files change -- `__init__.py`, by the + # version string alone, and `declaration.py`, the declarative builder. Our + # whole surface is `Controller`, `homie.DiscoveredDevice` and structural + # conformance to `MqttControllerTransport`; nothing here imports + # `declaration`. The suite is green against 0.23.1 unchanged. + # + # The cost is a release here per SDK minor, which is real: 0.23.0 and 0.23.1 + # both shipped the same day the previous ceiling was set. Still the right + # trade while the SDK is pre-1.0. + "ebus-sdk>=0.19.0,<0.24", ] [project.urls] diff --git a/uv.lock b/uv.lock index 032ddab..32f6b85 100644 --- a/uv.lock +++ b/uv.lock @@ -1402,7 +1402,7 @@ requires-dist = [{ name = "span-panel-api", editable = "." }] [[package]] name = "span-panel-api-schema-1" -version = "0.1.0b6" +version = "0.1.0b7" source = { editable = "packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, @@ -1411,7 +1411,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "ebus-sdk", specifier = ">=0.19.0,<0.23" }, + { name = "ebus-sdk", specifier = ">=0.19.0,<0.24" }, { name = "span-panel-api", editable = "." }, ] From 446bf3896dd753b7d18d5feaac5de3df226c4c5c Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:53:03 -0700 Subject: [PATCH 108/115] fix(client): a rebooting panel answers 502, and that ended the redispatch Caught on a live firmware upgrade, on two Home Assistant instances watching one panel. Both stayed on the old parser; neither recovered without a manual reload. 11:22:07 MQTT disconnected abnormally 11:25:15 Client rebuild - CA fetch failed: HTTP 502 11:26:15 MQTT reconnects, redispatch fires get_homie_schema -> HTTP 502 -> SpanPanelAPIError Task exception was never retrieved `_fetch_schema_with_retry` exists because a panel accepts MQTT before it serves HTTP. It caught `SpanPanelConnectionError` and `SpanPanelTimeoutError` -- two of the three ways that manifests. The third is the one a real reboot produces: the panel answers, with 502, because a booting device brings its network stack and reverse proxy up before the application behind them. That exception was not in the except clause, so the FIRST attempt raised out of the retry loop, out of the fire-and-forget task, and the parser was never swapped -- the exact failure the redispatch was written to prevent. Three changes, each mutation-verified: * `get_homie_schema` raises `SpanPanelServerError` for any 5xx, carrying the status. "Not ready yet" is a different fact from a 4xx, which will not fix itself, and the retry must be able to tell them apart. The class already existed and was not used here. * The retry catches it, and the window is sized from the observed reboot rather than guessed: five attempts capped at 8s gave up after ~23 seconds against a panel that took four minutes to return and was still serving 502 then. Twelve attempts backing off to 30s covers it. * Nothing escapes the redispatch task. An unexpected failure surfaced as a bare `Task exception was never retrieved` while the parser silently stayed put. Now logged at ERROR naming the consequence and the remedy, because a reload is the user's only move and nothing else was going to say so. Also closes the provenance chain that `ebus-panel-sim` 0.6.1 unblocked earlier: `power-flows` re-vendored at 0.3, spec re-pinned to 7ee7ca9, peer re-pinned to panelbench e757910, and the stale device pins corrected to the versions the specification has carried since before we pinned (0.14 / 0.4 / 0.15). 898 green with both peer checkouts configured. --- CHANGELOG.md | 13 ++++ .../schema-1/spec/catalogs/power-flows.json | 2 +- .../span_panel_api_schema_1/spec_lock.json | 16 ++--- pyproject.toml | 2 +- src/span_panel_api/auth.py | 24 ++++++- src/span_panel_api/mqtt/client.py | 32 ++++++++- tests/test_auth_and_homie_helpers.py | 46 ++++++++++++- tests/test_redispatch_on_reconnect.py | 67 ++++++++++++++++++- uv.lock | 2 +- 9 files changed, 187 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cecd3e3..fa40275 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0b8] + +### Fixed + +- **A panel answering `502` while it reboots no longer costs the automatic reload.** When a panel upgrades its firmware it drops MQTT, comes back, and serves HTTP a little later — and a booting device brings its network stack and reverse proxy up before + the application behind them, so the schema fetch is answered with `502` rather than refused. The retry that exists for exactly this handled "cannot reach" and "timed out" but not "answered, with 502", so the first attempt raised straight out of the loop, + out of the fire-and-forget task that called it, and the parser was never swapped. Caught on two Home Assistant instances watching one panel through the same live upgrade: both logged `Task exception was never retrieved`, both stayed on the old parser, + and neither recovered without a manual reload. `get_homie_schema` now raises `SpanPanelServerError` for any 5xx — "not ready yet", distinct from a 4xx that will not fix itself — and the retry treats it as retryable. +- **The wait is now the length of a real reboot.** Five attempts backing off to 8s gave up after about 23 seconds. The observed upgrade took four minutes from MQTT dropping to the broker returning, with HTTP still answering 502 at that point. Twelve + attempts backing off to 30s covers it. +- **Nothing escapes the redispatch task any more.** An unexpected failure there used to surface as a bare `Task exception was never retrieved` while the parser silently stayed on the old generation — the failure the redispatch exists to prevent, reached by + another route. It is now logged at ERROR naming the consequence and the remedy, because a reload is the user's only move and nothing else was going to tell them. + ## [3.0.0b7] ### Changed diff --git a/packages/schema-1/spec/catalogs/power-flows.json b/packages/schema-1/spec/catalogs/power-flows.json index 002b2f8..727cbfe 100644 --- a/packages/schema-1/spec/catalogs/power-flows.json +++ b/packages/schema-1/spec/catalogs/power-flows.json @@ -3,7 +3,7 @@ "schema_version": "property-schema-v1", "kind": "capability-catalog", "capability": "energy.ebus.capability.power-flows", - "version": "0.2", + "version": "0.3", "status": "DRAFT", "date": "2026-08-20", "properties": { diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json index e2deb01..3745b2a 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -7,15 +7,15 @@ "data_model_version": ">=1.0,<2.0" }, "spec_repo": "https://github.com/electrification-bus/specification", - "synced_commit": "4085c684f9a79bb3c25086112ba08c1f967f63c8", - "synced_date": "2026-08-20", + "synced_commit": "7ee7ca93b19c3de3d61be44f01887ba9557dd803", + "synced_date": "2026-08-21", "framework": "0.9", "peer": { "repo": "https://github.com/SpanPanel/panelbench", "ref": "main", "role": "publisher", - "commit": "6c649e53572577db81d7806dbbb79b9dd712abb2", - "synced_commit": "4085c684f9a79bb3c25086112ba08c1f967f63c8", + "commit": "e7579104a04dfb381ba89bfa1cbbe76e2c2d2058", + "synced_commit": "7ee7ca93b19c3de3d61be44f01887ba9557dd803", "firmware_range": "r202633+", "fixtures": { "tree": "tests/conformance/fixtures/golden_tree.json", @@ -34,7 +34,7 @@ "load-shed": "0.3", "meter": "0.4", "pcs": "0.3", - "power-flows": "0.2", + "power-flows": "0.3", "shed": "0.2", "shed-forecast": "0.1", "soc": "0.2", @@ -42,9 +42,9 @@ "switch": "0.3" }, "devices": { - "distribution-enclosure": "0.12", - "circuit": "0.3", - "bess": "0.14" + "distribution-enclosure": "0.14", + "circuit": "0.4", + "bess": "0.15" }, "registries": { "capability-types": "0.19", diff --git a/pyproject.toml b/pyproject.toml index 00ef5b4..113d17a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "3.0.0b7" +version = "3.0.0b8" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} diff --git a/src/span_panel_api/auth.py b/src/span_panel_api/auth.py index ffee86a..cc6342b 100644 --- a/src/span_panel_api/auth.py +++ b/src/span_panel_api/auth.py @@ -15,7 +15,13 @@ import httpx from ._http import _build_url, _get_client -from .exceptions import SpanPanelAPIError, SpanPanelAuthError, SpanPanelConnectionError, SpanPanelTimeoutError +from .exceptions import ( + SpanPanelAPIError, + SpanPanelAuthError, + SpanPanelConnectionError, + SpanPanelServerError, + SpanPanelTimeoutError, +) from .models import HomieSchemaTypes, V2AuthResponse, V2HomieSchema, V2StatusInfo @@ -187,8 +193,22 @@ async def get_homie_schema( except httpx.TimeoutException as exc: raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc + if response.status_code >= 500: + # A rebooting panel answers 502 from its front end while the application + # behind it is still starting. That is "not ready yet", not "wrong" -- + # and it is the ordinary shape of a firmware upgrade, because a device + # brings its network stack and proxy up before its application. Raised as + # a distinct class so a caller can retry it and fail fast on a 4xx, which + # will not fix itself. + raise SpanPanelServerError( + f"Panel not ready: HTTP {response.status_code} fetching the Homie schema", + status_code=response.status_code, + ) if response.status_code != 200: - raise SpanPanelAPIError(f"Failed to fetch Homie schema: HTTP {response.status_code}") + raise SpanPanelAPIError( + f"Failed to fetch Homie schema: HTTP {response.status_code}", + status_code=response.status_code, + ) data: dict[str, object] = response.json() diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index e1e4ff2..38e25bc 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -823,7 +823,20 @@ async def _fetch_schema_with_retry(self) -> V2HomieSchema | None: for _ in range(_REDISPATCH_RETRY_ATTEMPTS): try: return await get_homie_schema(self._host, port=self._panel_http_port, httpx_client=self._httpx_client) - except (SpanPanelConnectionError, SpanPanelTimeoutError) as exc: + except ( + SpanPanelConnectionError, + SpanPanelTimeoutError, + # The third way HTTP lags the broker, and the one a real upgrade + # actually produced: the panel answers, with 502. Its front end is + # up while the application behind it is still starting, which is + # the ordinary order for a booting device. Omitting this meant the + # first attempt raised straight out of this loop, out of the + # fire-and-forget task that called it, and the parser was never + # swapped -- observed on two Home Assistant instances watching one + # panel through the same upgrade, neither of which recovered + # without a manual reload. + SpanPanelServerError, + ) as exc: last = exc await asyncio.sleep(delay) delay = min(delay * 2, _REDISPATCH_RETRY_MAX_S) @@ -862,6 +875,23 @@ async def _redispatch_if_generation_changed(self) -> None: """ try: await self._redispatch_once() + except Exception: # pylint: disable=broad-exception-caught + # Nothing may escape here. This runs as a fire-and-forget task, so an + # escaping exception becomes "Task exception was never retrieved" in + # the log and the parser silently stays on the old generation -- + # which is the failure this whole method exists to prevent, arrived at + # by a different route. That is not hypothetical: a 502 from a + # rebooting panel did exactly this on two live installs. + # + # Logged at ERROR with the consequence spelled out, because the user's + # remedy is a reload and nothing else will tell them so. + _LOGGER.error( + "Could not follow the panel's schema-generation change; the %r parser is " + "unchanged and its data will read as missing. Reload the integration once " + "the panel is fully back up.", + self._data_model_version, + exc_info=True, + ) finally: # Released only when the swap is finished, not when the fetch is. # Clearing it after the fetch left a window that the slowest step in diff --git a/tests/test_auth_and_homie_helpers.py b/tests/test_auth_and_homie_helpers.py index 5c5c0cd..9c6c7db 100644 --- a/tests/test_auth_and_homie_helpers.py +++ b/tests/test_auth_and_homie_helpers.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest @@ -11,7 +11,12 @@ from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator from span_panel_api_schema_0.consumer import HomieDeviceConsumer, _parse_int from span_panel_api.auth import _int, download_ca_cert, get_homie_schema -from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelTimeoutError +from span_panel_api.exceptions import ( + SpanPanelAPIError, + SpanPanelConnectionError, + SpanPanelServerError, + SpanPanelTimeoutError, +) # --------------------------------------------------------------------------- # auth._int edge cases (lines 29-31) @@ -34,6 +39,17 @@ def test_string_parsed(self) -> None: # --------------------------------------------------------------------------- +def _mock_response(method: str, status_code: int) -> AsyncMock: + """A client whose request completes and returns `status_code`.""" + response = MagicMock() + response.status_code = status_code + mock = AsyncMock() + setattr(mock, method, AsyncMock(return_value=response)) + mock.__aenter__ = AsyncMock(return_value=mock) + mock.__aexit__ = AsyncMock(return_value=False) + return mock + + def _mock_client(method: str, side_effect: Exception) -> AsyncMock: mock = AsyncMock() setattr(mock, method, AsyncMock(side_effect=side_effect)) @@ -78,6 +94,32 @@ async def test_timeout_error(self) -> None: with pytest.raises(SpanPanelTimeoutError): await get_homie_schema("192.168.1.1") + @pytest.mark.asyncio + @pytest.mark.parametrize("status", [500, 502, 503, 504]) + async def test_a_server_status_is_not_ready_rather_than_wrong(self, status: int) -> None: + """A rebooting panel answers from its front end while the app behind it starts. + + Raised as `SpanPanelServerError` so a caller can tell "not yet" from + "no". The redispatch retry depends on this distinction: a live firmware + upgrade produced 502 here, the retry loop did not catch the general + `SpanPanelAPIError` it used to be, and the parser was never swapped. + """ + with patch("span_panel_api._http.httpx.AsyncClient") as cls: + cls.return_value = _mock_response("get", status) + with pytest.raises(SpanPanelServerError) as caught: + await get_homie_schema("192.168.1.1") + assert caught.value.status_code == status + + @pytest.mark.asyncio + @pytest.mark.parametrize("status", [401, 404]) + async def test_a_client_status_is_not_retryable(self, status: int) -> None: + """These do not fix themselves, so they must not look like "not ready yet".""" + with patch("span_panel_api._http.httpx.AsyncClient") as cls: + cls.return_value = _mock_response("get", status) + with pytest.raises(SpanPanelAPIError) as caught: + await get_homie_schema("192.168.1.1") + assert not isinstance(caught.value, SpanPanelServerError) + # --------------------------------------------------------------------------- # homie._parse_int failure path (lines 51-52) diff --git a/tests/test_redispatch_on_reconnect.py b/tests/test_redispatch_on_reconnect.py index a06749b..a3e2d22 100644 --- a/tests/test_redispatch_on_reconnect.py +++ b/tests/test_redispatch_on_reconnect.py @@ -30,7 +30,7 @@ import pytest -from span_panel_api.exceptions import SpanPanelConnectionError +from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelServerError from span_panel_api.mqtt.client import _REDISPATCH_RETRY_ATTEMPTS, SpanMqttClient from span_panel_api.mqtt.models import MqttClientConfig @@ -299,3 +299,68 @@ async def test_a_raising_consumer_does_not_break_the_swap() -> None: assert client.data_model_version == "1.0", "the swap must stand" assert reached == ["second"], "one raising subscriber must not starve the others" + + +@pytest.mark.asyncio +async def test_a_rebooting_panel_answering_502_is_waited_for_not_abandoned() -> None: + """The failure that cost a live firmware upgrade its automatic reload. + + A panel accepts MQTT before it serves HTTP, and the retry loop above exists + for that. But there are three ways HTTP lags the broker, and this loop + originally handled two: it caught "cannot reach" and "timed out" and not + "answered, with 502". A booting device brings its network stack and reverse + proxy up before the application behind them, so 502 is the *ordinary* shape, + not an exotic one. + + Because `SpanPanelServerError` was not caught, the very first attempt raised + straight out of the loop, out of the fire-and-forget task that called it, and + the parser was never swapped. Observed on two Home Assistant instances + watching one panel through the same upgrade: both logged `Task exception was + never retrieved`, both stayed on the flat parser, and neither recovered + without a manual reload. + """ + client, _ = _client(None) + before = client.adapter + attempts = 0 + + def _five_oh_two_then_ready(*_a: object, **_k: object) -> _Schema: + nonlocal attempts + attempts += 1 + if attempts < 3: + raise SpanPanelServerError("Panel not ready: HTTP 502 fetching the Homie schema", 502) + return _Schema("1.0") + + with ( + patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_five_oh_two_then_ready), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_INITIAL_S", 0), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_MAX_S", 0), + ): + await _panel_publishes_version(client, "1.0") + + assert attempts >= 3, "a 502 must be retried rather than ending the attempt" + assert client.adapter is not before, "the parser must swap once the panel answers" + + +@pytest.mark.asyncio +async def test_an_unexpected_failure_leaves_a_usable_message_rather_than_a_bare_traceback( + caplog: pytest.LogCaptureFixture, +) -> None: + """Nothing may escape the fire-and-forget task. + + An escaping exception surfaces as "Task exception was never retrieved" and + the parser silently stays on the old generation -- the exact failure this + method exists to prevent, reached by a different route. The user's remedy is + a reload, and nothing else is going to tell them so. + """ + client, _ = _client(None) + before = client.adapter + + with patch( + "span_panel_api.mqtt.client.get_homie_schema", + side_effect=RuntimeError("something nobody predicted"), + ): + await _panel_publishes_version(client, "1.0") + + assert client.adapter is before + assert "Reload the integration" in caplog.text + assert "something nobody predicted" in caplog.text diff --git a/uv.lock b/uv.lock index 32f6b85..d4c5485 100644 --- a/uv.lock +++ b/uv.lock @@ -1323,7 +1323,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "3.0.0b7" +version = "3.0.0b8" source = { editable = "." } dependencies = [ { name = "httpx" }, From f5f2d4511937ff764482118176e8694ee9ba4d05 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:08:40 -0700 Subject: [PATCH 109/115] fix(client): widen the retry window, which b8 was supposed to and did not b8 taught the schema fetch to treat a 502 as "not ready yet". It shipped with the old five attempts capped at eight seconds -- roughly twenty-three seconds against a panel observed taking four minutes to return, still answering 502 when the broker came back. So the fix was half a fix: the exception was caught and the loop gave up before the panel could answer. The widening was written in the same change and lost. Its edit script asserted on a second substitution, the assert failed, the file was never written, and the re-do covered only the except clause. Nothing failed afterwards, because catching the 502 and then giving up early looks exactly like working -- the suite passed, the release went out, and the published wheel reported `attempts: 5, max backoff: 8.0` when a post-publish check read it back out of PyPI. Twelve attempts backing off to thirty seconds is 241 seconds, a little over the observed four-minute reboot. Pinned by a test that sums the window and compares it against that reboot rather than asserting the constants individually. A test on the constants is the only thing that would have caught this, and there was none -- the three of them only mean anything together, which is also why the assertion is on the total. --- CHANGELOG.md | 8 +++++++ pyproject.toml | 2 +- src/span_panel_api/mqtt/client.py | 16 +++++++++++-- tests/test_redispatch_on_reconnect.py | 33 ++++++++++++++++++++++++++- uv.lock | 2 +- 5 files changed, 56 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa40275..b626cd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0b9] + +### Fixed + +- **The retry window for a rebooting panel is actually widened this time.** b8 taught the schema fetch to treat a `502` as "not ready yet" but shipped with the old five attempts capped at eight seconds — about twenty-three seconds against a panel observed + taking four minutes to come back, still answering 502 when the broker returned. The widening was written, lost to a failed edit in the same change, and shipped without it; nothing failed, because catching the 502 and giving up early looks exactly like + working. Now twelve attempts backing off to thirty seconds, a little over four minutes, and pinned by a test that asserts the total window outlasts the observed reboot rather than checking the constants individually. + ## [3.0.0b8] ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 113d17a..81ad2a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "3.0.0b8" +version = "3.0.0b9" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 38e25bc..df85fd3 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -48,9 +48,21 @@ # Re-reading the schema after a suspected generation change. Bounded because the # caller is a fire-and-forget task on a live connection, and generous enough to # outlast a panel that is still binding its HTTP port after a restart. -_REDISPATCH_RETRY_ATTEMPTS = 5 +_REDISPATCH_RETRY_ATTEMPTS = 12 _REDISPATCH_RETRY_INITIAL_S = 1.0 -_REDISPATCH_RETRY_MAX_S = 8.0 +_REDISPATCH_RETRY_MAX_S = 30.0 +"""How long to wait for the panel's HTTP endpoint after it returns on MQTT. + +Sized from a live firmware upgrade rather than guessed. The panel dropped MQTT at +11:22:07 and the broker was back at 11:26:15 -- four minutes -- and its HTTP +front end was still answering 502 at that moment. Five attempts capped at 8s +gives up after about 23 seconds, which is not the same order of magnitude as a +device that is still booting: catching the 502 buys nothing if the loop stops +before the panel is ready. + +Twelve attempts backing off to 30s is a little over four minutes. Each one is a +single GET, and the panel is the only thing that can end the wait. +""" def _metadata_for_the_log() -> tuple[list[str], str]: diff --git a/tests/test_redispatch_on_reconnect.py b/tests/test_redispatch_on_reconnect.py index a3e2d22..6887d3b 100644 --- a/tests/test_redispatch_on_reconnect.py +++ b/tests/test_redispatch_on_reconnect.py @@ -31,7 +31,12 @@ import pytest from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelServerError -from span_panel_api.mqtt.client import _REDISPATCH_RETRY_ATTEMPTS, SpanMqttClient +from span_panel_api.mqtt.client import ( + _REDISPATCH_RETRY_ATTEMPTS, + _REDISPATCH_RETRY_INITIAL_S, + _REDISPATCH_RETRY_MAX_S, + SpanMqttClient, +) from span_panel_api.mqtt.models import MqttClientConfig from conftest import SERIAL @@ -364,3 +369,29 @@ async def test_an_unexpected_failure_leaves_a_usable_message_rather_than_a_bare_ assert client.adapter is before assert "Reload the integration" in caplog.text assert "something nobody predicted" in caplog.text + + +def test_the_retry_window_outlasts_a_real_panel_reboot() -> None: + """Catching the 502 buys nothing if the loop gives up before the panel is ready. + + Measured rather than assumed. On a live firmware upgrade the panel dropped + MQTT at 11:22:07 and the broker was back at 11:26:15 — four minutes — and its + HTTP front end was still answering 502 at that moment, which is when this + loop starts. + + Pinned as a total because the three constants only mean something together, + and because the widening was written once, lost to a failed edit, and shipped + without it. Nothing failed: the 502 was caught and the loop still gave up + after twenty-three seconds. A test on the constants is the only thing that + would have noticed. + """ + delay = _REDISPATCH_RETRY_INITIAL_S + total = 0.0 + for _ in range(_REDISPATCH_RETRY_ATTEMPTS): + total += delay + delay = min(delay * 2, _REDISPATCH_RETRY_MAX_S) + + observed_reboot_s = 4 * 60 + assert total >= observed_reboot_s, ( + f"the retry window is {total:.0f}s, shorter than the {observed_reboot_s}s reboot " "this loop exists to wait out" + ) diff --git a/uv.lock b/uv.lock index d4c5485..eba3e3d 100644 --- a/uv.lock +++ b/uv.lock @@ -1323,7 +1323,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "3.0.0b8" +version = "3.0.0b9" source = { editable = "." } dependencies = [ { name = "httpx" }, From 9cd2f6fca40e2538fbc46d545ee72c97f9690e16 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:30:28 -0700 Subject: [PATCH 110/115] fix(auth,client): the 502 fix covered the shape, not the class Adversarial review of b8/b9 found the fix aimed at what was observed rather than at what the observation was an instance of. Four more mid-boot answers had the identical "gives up forever" character, each verified empirically against httpx 0.28.1 rather than reasoned about: * `httpx.ReadError` and `httpx.WriteError` -- a panel resetting its listener mid-request; * `httpx.RemoteProtocolError`, "server closed connection without sending a response" -- exactly what a proxy restarting under load produces; * a `200` whose body is truncated, empty, or not an object -- a panel part-way through starting, answering with a success status and nothing usable. All four escaped `get_homie_schema` untranslated, skipped the caller's retry clause, and stranded the parser. `ConnectError` alone was never the right catch: transport failures are now `SpanPanelConnectionError` via `httpx.TransportError` -- with the timeout branch kept ahead of it, since `TimeoutException` is one -- and an unusable body is `SpanPanelServerError`, retryable for the same reason a 502 is. **The window ended in a sleep no attempt followed.** Attempts landed at 0, 1, 3, 7, 15, 31, 61, 91, 121, 151, 181, 211 and the function returned at 241. So the last request went out at 211s against a reboot sized at 240, and a panel ready at 220 was still abandoned -- and abandonment is sticky, because the triggers are the reconnect edge and the retained message and a panel that finishes booting produces neither again. The loop no longer sleeps after its final attempt, which also stops it holding `_redispatch_in_flight` and the warning for a pointless backoff, and a thirteenth attempt puts the last request at 241s. **The test for that window asserted the wrong quantity.** It summed every sleep, including the dead trailing one, and so restated the implementation's arithmetic with its off-by-one intact -- 241 >= 240, green, while the last GET was at 211. It now asserts the offset of the final attempt, which is the property a user actually gets, and a second test pins that there are N-1 sleeps for N attempts. The give-up warning promised recovery "until the next reconnect", which cannot arrive for the reason above; it now says a reload is needed. The `SpanPanelServerError` docstring said "Server error (500)" and now describes what it means. --- CHANGELOG.md | 13 ++++++ pyproject.toml | 2 +- src/span_panel_api/auth.py | 31 ++++++++++++-- src/span_panel_api/exceptions.py | 8 +++- src/span_panel_api/mqtt/client.py | 17 ++++++-- tests/test_auth_and_homie_helpers.py | 52 +++++++++++++++++++++++ tests/test_redispatch_on_reconnect.py | 60 +++++++++++++++++++++------ uv.lock | 2 +- 8 files changed, 163 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b626cd6..364dcc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0b10] + +### Fixed + +- **Four more ways a booting panel answers now count as "not ready" rather than as a hard failure.** b8 and b9 covered the 502 that a live upgrade produced; review found the fix had covered the observed shape rather than the class. A panel resetting its + listener mid-request raises `ReadError` or `WriteError`, a proxy that dies mid-request raises `RemoteProtocolError`, and a panel part-way through starting can answer `200` with a truncated or empty body. All four escaped untranslated, skipped the retry + entirely, and stranded the parser exactly as the 502 did. Transport failures are now `SpanPanelConnectionError` and an unusable body is `SpanPanelServerError`. +- **The last retry attempt happens after the reboot it is sized for.** The window ended with a sleep that no attempt followed: it read 241 seconds while the final request went out at 211, so a panel ready at 220 was still abandoned. The loop no longer + sleeps after its final attempt — which also stopped it holding the in-flight guard, and the warning, for a pointless extra backoff — and the last request now lands at 241 seconds. The test asserts that offset instead of summing the sleeps, which was + restating the implementation's own off-by-one. +- **The give-up warning no longer promises a recovery that cannot arrive.** It said data would read as missing "until the next reconnect". The triggers are the reconnect edge and the retained `data-model-version` message, and a panel that finishes booting + produces neither again, so exhausting the window means stuck until a reload. It now says so. + ## [3.0.0b9] ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 81ad2a9..37cff08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "3.0.0b9" +version = "3.0.0b10" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} diff --git a/src/span_panel_api/auth.py b/src/span_panel_api/auth.py index cc6342b..90bc32e 100644 --- a/src/span_panel_api/auth.py +++ b/src/span_panel_api/auth.py @@ -188,10 +188,18 @@ async def get_homie_schema( try: async with _get_client(httpx_client, timeout) as client: response = await client.get(url) - except httpx.ConnectError as exc: - raise SpanPanelConnectionError(f"Cannot reach panel at {host}") from exc except httpx.TimeoutException as exc: raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc + except httpx.TransportError as exc: + # Every way the connection itself can fail, not just a refused connect: + # `ReadError` and `WriteError` when a rebooting panel resets mid-request, + # and `RemoteProtocolError` when its proxy closes without answering -- + # which is exactly what a proxy restarting under load produces. Catching + # only `ConnectError` meant those escaped this function untranslated, + # skipped the caller's retry clause entirely, and stranded the parser the + # same way a 502 used to. `TimeoutException` is itself a `TransportError`, + # so it has to be caught first. + raise SpanPanelConnectionError(f"Cannot reach panel at {host}: {exc}") from exc if response.status_code >= 500: # A rebooting panel answers 502 from its front end while the application @@ -210,7 +218,24 @@ async def get_homie_schema( status_code=response.status_code, ) - data: dict[str, object] = response.json() + try: + parsed = response.json() + except ValueError as exc: + # A panel part-way through starting can answer 200 with a truncated or + # empty body. Retryable for the same reason a 502 is -- it is "not ready + # yet" wearing a different status -- and untranslated this had precisely + # the 502's old character: raised out of the caller's retry loop on the + # first attempt and left the parser where it was. + raise SpanPanelServerError( + f"Panel not ready: {host} answered 200 with a body that is not JSON", + status_code=response.status_code, + ) from exc + if not isinstance(parsed, dict): + raise SpanPanelServerError( + f"Panel not ready: {host} answered 200 with {type(parsed).__name__}, not an object", + status_code=response.status_code, + ) + data: dict[str, object] = parsed # Extract types — each value is a dict of property definitions raw_types = data.get("types", {}) diff --git a/src/span_panel_api/exceptions.py b/src/span_panel_api/exceptions.py index 75b5f8c..cb59d4d 100644 --- a/src/span_panel_api/exceptions.py +++ b/src/span_panel_api/exceptions.py @@ -30,7 +30,13 @@ def __init__(self, message: str, status_code: int | None = None) -> None: class SpanPanelServerError(SpanPanelAPIError): - """Server error (500).""" + """The panel answered, and the answer means "not ready yet". + + Any 5xx, and a 200 whose body cannot be a schema. Distinct from + `SpanPanelAPIError` because a caller can retry this and should not retry a + 4xx, which will not fix itself. A rebooting panel produces these for as long + as its front end is up and the application behind it is not. + """ class SpanPanelStaleDataError(SpanPanelError): diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index df85fd3..f05add1 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -48,7 +48,7 @@ # Re-reading the schema after a suspected generation change. Bounded because the # caller is a fire-and-forget task on a live connection, and generous enough to # outlast a panel that is still binding its HTTP port after a restart. -_REDISPATCH_RETRY_ATTEMPTS = 12 +_REDISPATCH_RETRY_ATTEMPTS = 13 _REDISPATCH_RETRY_INITIAL_S = 1.0 _REDISPATCH_RETRY_MAX_S = 30.0 """How long to wait for the panel's HTTP endpoint after it returns on MQTT. @@ -832,7 +832,7 @@ async def _fetch_schema_with_retry(self) -> V2HomieSchema | None: """ delay = _REDISPATCH_RETRY_INITIAL_S last: Exception | None = None - for _ in range(_REDISPATCH_RETRY_ATTEMPTS): + for attempt in range(_REDISPATCH_RETRY_ATTEMPTS): try: return await get_homie_schema(self._host, port=self._panel_http_port, httpx_client=self._httpx_client) except ( @@ -850,12 +850,21 @@ async def _fetch_schema_with_retry(self) -> V2HomieSchema | None: SpanPanelServerError, ) as exc: last = exc + if attempt == _REDISPATCH_RETRY_ATTEMPTS - 1: + # No sleep after the final attempt. It delays the warning by a + # full backoff for nothing, and holds `_redispatch_in_flight` + # -- so a panel that returns during it is ignored rather than + # retried. + break await asyncio.sleep(delay) delay = min(delay * 2, _REDISPATCH_RETRY_MAX_S) _LOGGER.warning( "Could not re-read the panel schema after %d attempts (%s). The active " - "parser is unchanged; if the panel's schema generation did change, its " - "data will read as missing until the next reconnect.", + "parser is unchanged, so if the panel's schema generation did change its " + "data will read as missing. Reload the integration once the panel is fully " + "back up: this will not retry on its own, because the triggers are the " + "reconnect edge and the retained data-model-version message, and a panel " + "that finishes booting produces neither again.", _REDISPATCH_RETRY_ATTEMPTS, last, ) diff --git a/tests/test_auth_and_homie_helpers.py b/tests/test_auth_and_homie_helpers.py index 9c6c7db..55332b6 100644 --- a/tests/test_auth_and_homie_helpers.py +++ b/tests/test_auth_and_homie_helpers.py @@ -221,3 +221,55 @@ async def test_get_homie_schema_injected_skips_constructor(self) -> None: mock_cls.assert_not_called() injected.aclose.assert_not_called() + + +class TestGetHomieSchemaNotReadyShapes: + """Every way a booting panel answers that is not a clean 5xx. + + Each of these used to escape `get_homie_schema` untranslated, skip the + caller's retry clause entirely, and strand the parser — the same failure the + 502 produced on a live upgrade, wearing a different exception. + """ + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "failure", + [ + httpx.ReadError("connection reset"), + httpx.WriteError("broken pipe"), + httpx.RemoteProtocolError("server closed connection without sending a response"), + ], + ids=["read-reset", "write-reset", "proxy-closed-without-answering"], + ) + async def test_a_transport_failure_is_a_connection_error(self, failure: Exception) -> None: + """A panel resetting its listener mid-request, and a proxy dying mid-request. + + `httpx.TimeoutException` is itself a `TransportError`, so the timeout + branch has to stay ahead of this one — covered by the timeout test above. + """ + with patch("span_panel_api._http.httpx.AsyncClient") as cls: + cls.return_value = _mock_client("get", failure) + with pytest.raises(SpanPanelConnectionError): + await get_homie_schema("192.168.1.1") + + @pytest.mark.asyncio + @pytest.mark.parametrize("body", ["", "{trunc", "null", "[]"], ids=["empty", "truncated", "null", "list"]) + async def test_a_200_that_cannot_be_a_schema_is_not_ready_rather_than_broken(self, body: str) -> None: + """A panel part-way through starting can answer 200 with nothing usable. + + Retryable for the same reason a 502 is: it is "not ready yet" wearing a + success status. The bounded attempt count makes retrying a genuinely + broken body cheap. + """ + response = MagicMock() + response.status_code = 200 + response.json = MagicMock(side_effect=(lambda: json.loads(body)) if body else ValueError("no content")) + mock = AsyncMock() + mock.get = AsyncMock(return_value=response) + mock.__aenter__ = AsyncMock(return_value=mock) + mock.__aexit__ = AsyncMock(return_value=False) + + with patch("span_panel_api._http.httpx.AsyncClient") as cls: + cls.return_value = mock + with pytest.raises(SpanPanelServerError): + await get_homie_schema("192.168.1.1") diff --git a/tests/test_redispatch_on_reconnect.py b/tests/test_redispatch_on_reconnect.py index 6887d3b..eba0dea 100644 --- a/tests/test_redispatch_on_reconnect.py +++ b/tests/test_redispatch_on_reconnect.py @@ -371,27 +371,63 @@ async def test_an_unexpected_failure_leaves_a_usable_message_rather_than_a_bare_ assert "something nobody predicted" in caplog.text -def test_the_retry_window_outlasts_a_real_panel_reboot() -> None: - """Catching the 502 buys nothing if the loop gives up before the panel is ready. +def test_the_last_attempt_outlasts_a_real_panel_reboot() -> None: + """What matters is when the final GET happens, not how long the function runs. Measured rather than assumed. On a live firmware upgrade the panel dropped MQTT at 11:22:07 and the broker was back at 11:26:15 — four minutes — and its HTTP front end was still answering 502 at that moment, which is when this loop starts. - Pinned as a total because the three constants only mean something together, - and because the widening was written once, lost to a failed edit, and shipped - without it. Nothing failed: the 502 was caught and the loop still gave up - after twenty-three seconds. A test on the constants is the only thing that - would have noticed. + **Asserted on the offset of the last attempt.** The first version of this test + summed every sleep and compared the total, which counted a trailing sleep that + no attempt followed: it read 241s while the last GET was at 211s, and a panel + ready at 220s would still have been abandoned. Summing the sleeps restates the + implementation's arithmetic, off-by-one included; the offset of the last + attempt is the property a user actually gets. """ delay = _REDISPATCH_RETRY_INITIAL_S - total = 0.0 - for _ in range(_REDISPATCH_RETRY_ATTEMPTS): - total += delay + offset = 0.0 + for attempt in range(_REDISPATCH_RETRY_ATTEMPTS): + if attempt == _REDISPATCH_RETRY_ATTEMPTS - 1: + break + offset += delay delay = min(delay * 2, _REDISPATCH_RETRY_MAX_S) observed_reboot_s = 4 * 60 - assert total >= observed_reboot_s, ( - f"the retry window is {total:.0f}s, shorter than the {observed_reboot_s}s reboot " "this loop exists to wait out" + assert offset >= observed_reboot_s, ( + f"the last attempt is at {offset:.0f}s, inside the {observed_reboot_s}s reboot this " + "loop exists to wait out — a panel ready after that is abandoned" ) + + +@pytest.mark.asyncio +async def test_the_loop_does_not_sleep_after_its_final_attempt() -> None: + """A trailing sleep buys nothing and costs two things. + + It delays the warning by a full backoff, and it holds `_redispatch_in_flight` + for that long — so a panel that comes back during it is ignored rather than + retried, which is the opposite of what the wait is for. + """ + client, _ = _client(None) + slept: list[float] = [] + attempts = 0 + + def _never_ready(*_a: object, **_k: object) -> _Schema: + nonlocal attempts + attempts += 1 + raise SpanPanelServerError("Panel not ready: HTTP 502", 502) + + async def _record(seconds: float) -> None: + slept.append(seconds) + + with ( + patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_never_ready), + patch("span_panel_api.mqtt.client.asyncio.sleep", _record), + ): + assert await client._fetch_schema_with_retry() is None + + assert attempts == _REDISPATCH_RETRY_ATTEMPTS + assert ( + len(slept) == _REDISPATCH_RETRY_ATTEMPTS - 1 + ), f"{attempts} attempts should be separated by {attempts - 1} sleeps, got {len(slept)}" diff --git a/uv.lock b/uv.lock index eba3e3d..cfab8e0 100644 --- a/uv.lock +++ b/uv.lock @@ -1323,7 +1323,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "3.0.0b9" +version = "3.0.0b10" source = { editable = "." } dependencies = [ { name = "httpx" }, From 9862c3c3381957be7da58d48bf92a9f4f1d2ffbf Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:38:50 -0700 Subject: [PATCH 111/115] fix(client): stop giving up on a rebooting panel The bound was wrong twice for the same reason: sized against a reboot somebody had measured, and the next reboot was not that reboot. There is no third measurement that fixes that, because the quantity is not ours to know. Giving up has nothing on the other side of the scale. The triggers for another attempt are the reconnect edge and the retained data-model-version message, and a panel that finishes booting after the loop expired produces neither -- so exhausting a bound does not mean "try again later", it means stranded until a human reloads. And waiting costs no freshness: energy sensors hold their last reading through an outage on their own grace period, fifteen minutes by default, which exists precisely so a gap does not become an `unknown` and a statistics spike. That mechanism is untouched by how long this waits, and it was the only thing that would have justified a deadline here. What remains is one GET every thirty seconds to a device on the local network. The interval settles rather than growing -- 1, 2, 4, 8, 16, 30 and then 30 -- because backing off without a ceiling would mean a panel that took a while to return was then ignored for longer than it took. Worst case between the panel answering and this noticing is one interval, however long the wait has run. Unbounded is only safe because cancellation is prompt, so that is now tested rather than assumed: `close()` cancels the task and the cancellation lands inside the sleep. Logging is first-then-occasional. A panel that never returns would otherwise write a line every thirty seconds forever, and the second line is worth no more than the first. Two mutations verified. Re-bounding the loop fails. Removing the ceiling from the backoff also fails now -- it did not before, because the test computed the sequence itself and asserted on its own arithmetic, which is the same fault as the window test it replaced. It observes the sleeps the real function performs. --- CHANGELOG.md | 10 ++ src/span_panel_api/mqtt/client.py | 75 ++++++------ tests/test_redispatch_on_reconnect.py | 157 +++++++++++++++++--------- 3 files changed, 154 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 364dcc9..34b7096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [3.0.0b10] +### Changed + +- **The wait for a panel to finish rebooting no longer gives up.** It used to stop after a fixed number of attempts, and that bound was wrong twice for the same reason: it was sized against a reboot somebody had measured, and the next reboot was not that + reboot. Giving up has nothing to recommend it — the only things that start another attempt are the reconnect edge and the panel republishing its data-model version, and a panel that finishes booting after the wait expired produces neither, so running out + of attempts means stranded until somebody reloads by hand. It now waits as long as the panel takes. +- **Waiting costs nothing you were relying on.** Energy sensors already hold their last reading through an outage on their own grace period — fifteen minutes by default, configurable — which exists precisely so a gap does not become an `unknown` and a + statistics spike. That is untouched by how long this waits, and it was the only thing that would have justified a deadline. What is left is one request every thirty seconds to a device on your own network. +- **The retry interval settles at thirty seconds rather than growing.** Backing off without a ceiling would mean a panel that took a while to return was then ignored for longer than it took. The gap goes 1, 2, 4, 8, 16, 30 and stays there, so once your + panel is answering it is noticed within half a minute however long the wait has already run. + ### Fixed - **Four more ways a booting panel answers now count as "not ready" rather than as a hard failure.** b8 and b9 covered the 502 that a live upgrade produced; review found the fix had covered the observed shape rather than the class. A panel resetting its diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index f05add1..4d2601d 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -48,9 +48,9 @@ # Re-reading the schema after a suspected generation change. Bounded because the # caller is a fire-and-forget task on a live connection, and generous enough to # outlast a panel that is still binding its HTTP port after a restart. -_REDISPATCH_RETRY_ATTEMPTS = 13 _REDISPATCH_RETRY_INITIAL_S = 1.0 _REDISPATCH_RETRY_MAX_S = 30.0 +_REDISPATCH_LOG_EVERY = 20 """How long to wait for the panel's HTTP endpoint after it returns on MQTT. Sized from a live firmware upgrade rather than guessed. The panel dropped MQTT at @@ -817,7 +817,7 @@ def _generation_appears_changed(self) -> bool: return active != observed async def _fetch_schema_with_retry(self) -> V2HomieSchema | None: - """Read the panel's REST schema, allowing for HTTP trailing the broker. + """Read the panel's REST schema, waiting for HTTP to catch up with the broker. A panel that has just restarted accepts MQTT before it serves HTTP — the broker is listening while the application is still binding its port. The @@ -826,49 +826,56 @@ async def _fetch_schema_with_retry(self) -> V2HomieSchema | None: was no further edge to retry on, leaving the wrong parser in place for the rest of the session. - So this waits, briefly and boundedly. Returning None rather than raising - because the caller's job is to reconsider the parser, and being unable to - is not a reason to disturb a connection that is otherwise working. + **This waits as long as it takes, and that is deliberate.** Every bounded + version of it has been wrong, twice for the same reason: the bound was + sized against a reboot somebody had measured, and the next reboot was not + that reboot. Giving up has no upside to weigh against being wrong. The + triggers for another attempt are the reconnect edge and the retained + `data-model-version` message, and a panel that finishes booting after the + loop gave up produces neither — so exhausting a bound does not mean + "try again later", it means stranded until somebody reloads by hand. + + Nor does waiting cost the freshness of anything. Energy sensors already + hold their last valid reading through an outage on their own grace period, + which exists precisely so a gap does not become an `unknown` and a + statistics spike; that mechanism is untouched by how long this waits, and + it is the thing that would have justified a deadline here. What is left is + one HTTP GET every thirty seconds to a device on the local network, which + is less traffic than the ordinary snapshot poll. + + Ends on success, on cancellation — `close()` cancels this task, so unload + and shutdown are prompt — or on an error that is not the panel still + coming up, which is left to raise. """ delay = _REDISPATCH_RETRY_INITIAL_S - last: Exception | None = None - for attempt in range(_REDISPATCH_RETRY_ATTEMPTS): + attempts = 0 + while True: try: return await get_homie_schema(self._host, port=self._panel_http_port, httpx_client=self._httpx_client) except ( SpanPanelConnectionError, SpanPanelTimeoutError, - # The third way HTTP lags the broker, and the one a real upgrade - # actually produced: the panel answers, with 502. Its front end is - # up while the application behind it is still starting, which is - # the ordinary order for a booting device. Omitting this meant the - # first attempt raised straight out of this loop, out of the - # fire-and-forget task that called it, and the parser was never - # swapped -- observed on two Home Assistant instances watching one - # panel through the same upgrade, neither of which recovered - # without a manual reload. + # The panel answering rather than refusing: a 5xx from its front + # end while the application behind it starts, or a 200 carrying a + # body that cannot be a schema. The ordinary shape of a reboot, + # because a device brings its network stack and proxy up before + # its application -- and the shape that stranded two live installs + # when it was not caught here. SpanPanelServerError, ) as exc: - last = exc - if attempt == _REDISPATCH_RETRY_ATTEMPTS - 1: - # No sleep after the final attempt. It delays the warning by a - # full backoff for nothing, and holds `_redispatch_in_flight` - # -- so a panel that returns during it is ignored rather than - # retried. - break + attempts += 1 + if attempts == 1 or attempts % _REDISPATCH_LOG_EVERY == 0: + # First failure, then occasionally. A panel that never returns + # would otherwise write a line every thirty seconds forever, + # and the second line is worth no more than the first. + _LOGGER.warning( + "Panel is not serving its schema yet (%s). Attempt %d; still " + "waiting, and the parser stays as it is until it answers.", + exc, + attempts, + ) await asyncio.sleep(delay) delay = min(delay * 2, _REDISPATCH_RETRY_MAX_S) - _LOGGER.warning( - "Could not re-read the panel schema after %d attempts (%s). The active " - "parser is unchanged, so if the panel's schema generation did change its " - "data will read as missing. Reload the integration once the panel is fully " - "back up: this will not retry on its own, because the triggers are the " - "reconnect edge and the retained data-model-version message, and a panel " - "that finishes booting produces neither again.", - _REDISPATCH_RETRY_ATTEMPTS, - last, - ) - return None async def _redispatch_if_generation_changed(self) -> None: """Swap the parser when the panel comes back as a different schema generation. diff --git a/tests/test_redispatch_on_reconnect.py b/tests/test_redispatch_on_reconnect.py index eba0dea..59ae667 100644 --- a/tests/test_redispatch_on_reconnect.py +++ b/tests/test_redispatch_on_reconnect.py @@ -32,7 +32,6 @@ from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelServerError from span_panel_api.mqtt.client import ( - _REDISPATCH_RETRY_ATTEMPTS, _REDISPATCH_RETRY_INITIAL_S, _REDISPATCH_RETRY_MAX_S, SpanMqttClient, @@ -101,7 +100,7 @@ async def _panel_publishes_version(client: SpanMqttClient, version: str | None) # The refetch is scheduled rather than awaited, so the message callback can stay # synchronous. Let the loop drain it. # One turn per retry attempt, plus slack for the task itself. - for _ in range(_REDISPATCH_RETRY_ATTEMPTS + 4): + for _ in range(24): await asyncio.sleep(0) @@ -215,13 +214,15 @@ def _lags_then_answers(*_a: object, **_k: object) -> _Schema: @pytest.mark.asyncio -async def test_a_panel_that_never_serves_http_leaves_the_parser_alone() -> None: - """Bounded, and non-fatal when the bound is reached. - - MQTT is up or this path would not be running, so tearing the connection down over - an unreachable HTTP endpoint would turn a degraded panel into a dead integration. - A stale parser reports missing data rather than wrong data, because the two - schemas share no topic shape. +async def test_a_panel_that_is_not_serving_http_yet_leaves_the_parser_alone() -> None: + """Waiting must not disturb what is already working. + + MQTT is up or this path would not be running, so tearing the connection down + over an HTTP endpoint that has not come up would turn a panel that is merely + booting into a dead integration. The parser stays as it is while the wait + runs — a stale parser reports missing data rather than wrong data, because + the two schemas share no topic shape — and the wait keeps going rather than + giving up, because nothing else will start it again. """ client, _ = _client(None) before = client.adapter @@ -236,8 +237,16 @@ async def test_a_panel_that_never_serves_http_leaves_the_parser_alone() -> None: ): await _panel_publishes_version(client, "1.0") - assert client.adapter is before - assert not client._redispatch_in_flight, "the in-flight guard must clear on failure" + assert client.adapter is before + assert client._redispatch_in_flight, ( + "the guard is held for as long as the wait runs, so a second edge does not " "start a competing attempt" + ) + + # Cancelled directly rather than through `close()`, which this fixture's fake + # bridge cannot service. That the wait ends on cancellation is covered by + # `test_the_wait_ends_promptly_when_the_client_is_closed`. + for task in list(client._background_tasks): + task.cancel() @pytest.mark.asyncio @@ -371,63 +380,103 @@ async def test_an_unexpected_failure_leaves_a_usable_message_rather_than_a_bare_ assert "something nobody predicted" in caplog.text -def test_the_last_attempt_outlasts_a_real_panel_reboot() -> None: - """What matters is when the final GET happens, not how long the function runs. +@pytest.mark.asyncio +async def test_the_backoff_reaches_a_steady_state_rather_than_growing() -> None: + """Once the panel is up, the wait to notice it must stay short. + + Doubling without a ceiling would mean a panel that took a while to come back + was then ignored for longer than it took — minutes between attempts by the + time it is answering. The interval has to settle, so the worst case between + the panel being ready and this loop finding out is one interval however long + the wait has already run. + + **Observed from the loop, not recomputed.** The first version of this test + calculated the backoff sequence itself and asserted on its own arithmetic, + which passes just as happily when the ceiling is removed from the code — the + same mistake as the window test it replaced. These are the sleeps the real + function performed. + """ + client, _ = _client(None) + slept: list[float] = [] + attempts = 0 + + def _ready_eventually(*_a: object, **_k: object) -> _Schema: + nonlocal attempts + attempts += 1 + if attempts < 30: + raise SpanPanelServerError("Panel not ready: HTTP 502", 502) + return _Schema("1.0") + + async def _record(seconds: float) -> None: + slept.append(seconds) - Measured rather than assumed. On a live firmware upgrade the panel dropped - MQTT at 11:22:07 and the broker was back at 11:26:15 — four minutes — and its - HTTP front end was still answering 502 at that moment, which is when this - loop starts. + with ( + patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_ready_eventually), + patch("span_panel_api.mqtt.client.asyncio.sleep", _record), + ): + assert await client._fetch_schema_with_retry() is not None - **Asserted on the offset of the last attempt.** The first version of this test - summed every sleep and compared the total, which counted a trailing sleep that - no attempt followed: it read 241s while the last GET was at 211s, and a panel - ready at 220s would still have been abandoned. Summing the sleeps restates the - implementation's arithmetic, off-by-one included; the offset of the last - attempt is the property a user actually gets. - """ - delay = _REDISPATCH_RETRY_INITIAL_S - offset = 0.0 - for attempt in range(_REDISPATCH_RETRY_ATTEMPTS): - if attempt == _REDISPATCH_RETRY_ATTEMPTS - 1: - break - offset += delay - delay = min(delay * 2, _REDISPATCH_RETRY_MAX_S) - - observed_reboot_s = 4 * 60 - assert offset >= observed_reboot_s, ( - f"the last attempt is at {offset:.0f}s, inside the {observed_reboot_s}s reboot this " - "loop exists to wait out — a panel ready after that is abandoned" + assert slept[0] == _REDISPATCH_RETRY_INITIAL_S, "it should start responsive" + assert max(slept) == _REDISPATCH_RETRY_MAX_S, "and never wait longer than the ceiling" + assert slept[-1] == _REDISPATCH_RETRY_MAX_S, "settling there rather than continuing to grow" + assert _REDISPATCH_RETRY_MAX_S <= 30.0, ( + "a steady-state gap longer than half a minute is too long to leave a panel " "that is already answering" ) @pytest.mark.asyncio -async def test_the_loop_does_not_sleep_after_its_final_attempt() -> None: - """A trailing sleep buys nothing and costs two things. - - It delays the warning by a full backoff, and it holds `_redispatch_in_flight` - for that long — so a panel that comes back during it is ignored rather than - retried, which is the opposite of what the wait is for. +async def test_the_wait_does_not_end_on_its_own() -> None: + """There is no attempt count to exhaust, and that is the point. + + Every bounded version of this was wrong, twice, for the same reason: the + bound was sized against a reboot somebody had measured and the next reboot + was not that reboot. Giving up has nothing to recommend it — the triggers for + another attempt are the reconnect edge and the retained message, and a panel + that finishes booting afterwards produces neither, so exhausting a bound + means stranded until a human reloads. """ client, _ = _client(None) - slept: list[float] = [] attempts = 0 - def _never_ready(*_a: object, **_k: object) -> _Schema: + def _ready_far_later(*_a: object, **_k: object) -> _Schema: nonlocal attempts attempts += 1 - raise SpanPanelServerError("Panel not ready: HTTP 502", 502) + if attempts < 40: # well past any bound this ever had + raise SpanPanelServerError("Panel not ready: HTTP 502", 502) + return _Schema("1.0") + + with ( + patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_ready_far_later), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_INITIAL_S", 0), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_MAX_S", 0), + ): + assert await client._fetch_schema_with_retry() is not None + + assert attempts == 40 - async def _record(seconds: float) -> None: - slept.append(seconds) + +@pytest.mark.asyncio +async def test_the_wait_ends_promptly_when_the_client_is_closed() -> None: + """Unbounded is only safe because cancellation is prompt. + + `close()` cancels every background task, and the cancellation lands inside + the sleep. Without this, waiting forever would mean a Home Assistant + shutdown or a config-entry unload waiting with it. + """ + client, _ = _client(None) with ( - patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_never_ready), - patch("span_panel_api.mqtt.client.asyncio.sleep", _record), + patch( + "span_panel_api.mqtt.client.get_homie_schema", + side_effect=SpanPanelServerError("Panel not ready: HTTP 502", 502), + ), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_INITIAL_S", 3600), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_MAX_S", 3600), ): - assert await client._fetch_schema_with_retry() is None + task = asyncio.create_task(client._fetch_schema_with_retry()) + await asyncio.sleep(0) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task - assert attempts == _REDISPATCH_RETRY_ATTEMPTS - assert ( - len(slept) == _REDISPATCH_RETRY_ATTEMPTS - 1 - ), f"{attempts} attempts should be separated by {attempts - 1} sleeps, got {len(slept)}" + assert task.cancelled() From 179b3bbbbbad6e0960f2f9ddbc00ce0499d5dcca Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:53:13 -0700 Subject: [PATCH 112/115] fix(schema-1): a panel with no MID reports GRID as the forming entity Observed on a live install: `Grid` on flat all night, `Unknown` from the moment it upgraded, with nothing about the site having changed. The field's source moved -- flat published a closed enum of source classes on the panel, v1.0 names the forming device on the MID's `grid` node -- and a panel with no battery has no MID, so the property has no publisher and the field went `None`. A missing MID settles the answer by elimination rather than leaving it open. `BATTERY` needs a BESS and a BESS brings a MID. `PV` cannot form a grid alone, because anything that can is a grid-forming inverter and therefore a MID. `NONE` describes a panel supplying nothing, which is a panel that is not publishing. What remains is a generator, and that is two cases of which only one reaches here: one wired through a MID is named by that MID and answered above, while one with no MID interface is what SPAN treats as the grid -- and is the only kind an install with no MID can have. The elimination therefore keeps holding if MID-integrated generators arrive, because they bring a MID. Deliberately not the same rule as `resolve_islanding_state`, which refuses this shortcut, and the counterexample that defeats it there is what supports it here: a generator-fed island IS islanded, so inferring on-grid from a missing MID would be wrong, while its grid-forming entity really is what SPAN calls the grid. Islanding is a safety fact about separation; this is a class of source. No worse than flat either, which is the bar: flat could not see an uninterfaced generator and published GRID regardless, so a panel upgrading keeps the answer it was already giving instead of losing it to the loss of a property. A MID that exists and has not answered still reports nothing -- genuinely unknown, and distinct from there being no islanding authority at all. Both mutations verified: reverting the no-MID case to `None` fails, and collapsing the silent-MID case into `GRID` fails. --- CHANGELOG.md | 4 +++ packages/schema-1/CHANGELOG.md | 19 +++++++++++ packages/schema-1/pyproject.toml | 2 +- .../src/span_panel_api_schema_1/panel.py | 33 ++++++++++++++++-- pyproject.toml | 2 +- tests/test_schema_one_panel.py | 34 ++++++++++++++++++- uv.lock | 4 +-- 7 files changed, 91 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34b7096..fa98287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0b11] + +Carries `span-panel-api-schema-1` 0.1.0b8, which restores `dominant_power_source` on a panel with no MID. No change in this distribution. + ## [3.0.0b10] ### Changed diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index 9d8848a..a25324a 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -7,6 +7,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is the parent/child device tree SPAN firmware `r202633+` publishes, identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version number. A release here means this parser changed, never that the panel did. +## [0.1.0b8] - 08/2026 + +Pre-release. Requires `span-panel-api` 3.0.0b4 or newer — unchanged. + +### Fixed + +- **`dominant_power_source` reports `GRID` on a panel with no MID, instead of nothing.** The field's source moved in v1.0: flat published a closed enum of source classes on the panel, v1.0 names the forming device on the MID's `grid` node. A panel with no + battery has no MID, so the property has no publisher and the field went `None` — observed on a live install that read `Grid` on flat all night and went unknown the moment it upgraded, with nothing about the site having changed. + + A missing MID settles the answer by elimination rather than leaving it open. `BATTERY` needs a BESS and a BESS brings a MID; `PV` cannot form a grid alone, because anything that can is a grid-forming inverter and therefore a MID; `NONE` describes a panel + supplying nothing, which is a panel that is not publishing. What remains is a generator, and that is two cases of which only one reaches here: a generator wired through a MID is named by that MID and answered before this point, while a generator with no + MID interface is what SPAN treats as the grid — and it is the only kind an install with no MID can have. The elimination therefore keeps holding if MID-integrated generators arrive, because they bring a MID. A site running off-grid without storage is not + a counterexample — it goes dark at sunset. + + This deliberately does not follow `resolve_islanding_state`, which refuses the same shortcut, and the counterexample that defeats it there is what supports it here: a generator-fed island **is** islanded, so inferring on-grid from a missing MID would be + wrong, while its grid-forming entity really is what SPAN calls the grid. It is also no worse than flat, which could not see an uninterfaced generator either and published `GRID` regardless. + + A MID that exists and has not answered still reports nothing. That is genuinely unknown, and distinct from there being no islanding authority at all. + ## [0.1.0b7] - 08/2026 Pre-release. Requires `span-panel-api` 3.0.0b4 or newer — unchanged. diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index af91d24..2a7b86d 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api-schema-1" -version = "0.1.0b7" +version = "0.1.0b8" description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api" authors = [ {name = "SpanPanel"} diff --git a/packages/schema-1/src/span_panel_api_schema_1/panel.py b/packages/schema-1/src/span_panel_api_schema_1/panel.py index 5b81d0f..34cb74d 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/panel.py +++ b/packages/schema-1/src/span_panel_api_schema_1/panel.py @@ -753,9 +753,38 @@ def resolve_dominant_power_source( exists, surface the addition separately: a changed value breaks automations silently, a new field cannot. - `None` rather than `UNKNOWN` when there is no MID or no answer at all, matching what - the field already does on a panel that publishes nothing. + **No MID at all means `GRID`, and that is an elimination rather than a guess.** + A commissioned MID is what SPAN has to island with, so its absence rules out every + other value this field can take. `BATTERY` needs a BESS, and a BESS brings a MID. + `PV` cannot form a grid on its own — anything that can is a grid-forming inverter, + which is a MID. `NONE` describes a panel supplying nothing, which is a panel that is + not publishing. That leaves a generator, which is two cases rather than one and only + one of them reaches here. A generator wired through a MID is named by that MID, so + the branch above answers and this one never runs. A generator with no MID interface + is what SPAN treats as the grid, and it is the only generator an install with no MID + can have. So the elimination holds now and keeps holding if MID-integrated generators + arrive: they bring a MID, and a MID is answered above. + + A site genuinely running off-grid without storage is not a counterexample; it goes + dark at sunset. + + This deliberately does **not** follow `resolve_islanding_state`, which refuses the + same shortcut. The two answer different questions and the counterexample that defeats + it there is the one that supports it here: a generator-fed island is islanded — so + inferring on-grid from a missing MID would be wrong — while its grid-forming entity + really is what SPAN calls the grid. Islanding is a safety fact about separation; this + is a class of source. + + It is also no worse than flat, which is the bar. Flat could not see an uninterfaced + generator either and published `GRID` regardless; a panel upgrading to v1.0 keeps the + answer it has been giving rather than losing it to the loss of a property. + + `None` only when a MID exists and has not answered. That is genuinely unknown — there + is an islanding authority and it has not said — and is distinct from there being none. """ + if mid is None: + return "GRID" + forming = text(mid, NODE_GRID, PROP_GRID_FORMING_ENTITY).strip() if not forming: return None diff --git a/pyproject.toml b/pyproject.toml index 37cff08..d52c57f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "3.0.0b10" +version = "3.0.0b11" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} diff --git a/tests/test_schema_one_panel.py b/tests/test_schema_one_panel.py index 0e8aad6..3d8550f 100644 --- a/tests/test_schema_one_panel.py +++ b/tests/test_schema_one_panel.py @@ -445,7 +445,39 @@ def test_an_unresolvable_forming_entity_cannot_escape_as_a_raw_id() -> None: assert resolve_dominant_power_source(stranger, {}) == "UNKNOWN" assert resolve_dominant_power_source(unmapped, {"wh-1": "energy.ebus.device.water-heater"}) == "UNKNOWN" - assert resolve_dominant_power_source(None, {}) is None + + +def test_a_panel_with_no_mid_reports_the_grid_as_forming() -> None: + """Elimination, not a guess, and it restores an answer flat already gave. + + A commissioned MID is what SPAN islands with, so its absence rules out every + other value this field can take. `BATTERY` needs a BESS and a BESS brings a + MID; `PV` cannot form a grid alone, because anything that can is a + grid-forming inverter and therefore a MID; `NONE` describes a panel supplying + nothing, which is a panel that is not publishing. What remains is a generator, + and that is two cases of which only one reaches here — one wired through a MID + is named by that MID and answered before this point, while one with no MID + interface is what SPAN treats as the grid, and is the only kind an install + with no MID can have. So this keeps holding if MID-integrated generators + arrive: they bring a MID. + + Observed: a live no-BESS panel read `Grid` on flat all night and went + `Unknown` the moment it upgraded, because the property moved onto a device + that install does not have. Nothing about the site changed. + """ + assert resolve_dominant_power_source(None, {}) == "GRID" + + +def test_a_mid_that_has_not_answered_is_unknown_rather_than_grid() -> None: + """Distinct from having no MID at all, and the distinction is the whole point. + + An islanding authority exists and has not said what is forming the grid. That + is genuinely unknown — unlike an install with no such authority, where the + answer is settled by what cannot be there. + """ + silent = _synthetic("mid", grid__grid_forming_entity="") + + assert resolve_dominant_power_source(silent, {}) is None def test_the_forming_device_is_named_readably_not_by_wire_id() -> None: diff --git a/uv.lock b/uv.lock index cfab8e0..b44a5d7 100644 --- a/uv.lock +++ b/uv.lock @@ -1323,7 +1323,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "3.0.0b10" +version = "3.0.0b11" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -1402,7 +1402,7 @@ requires-dist = [{ name = "span-panel-api", editable = "." }] [[package]] name = "span-panel-api-schema-1" -version = "0.1.0b7" +version = "0.1.0b8" source = { editable = "packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From 6c284c5f95f8538785a9882f541a597199ced0bb Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:22:16 -0700 Subject: [PATCH 113/115] feat: carry vendor properties on modelled devices to the consumer The schema is vendor-extensible and adoption covered half of it. A device type nothing here models is adopted with its readings; a new property on the BESS, a charger, a circuit or the panel became a `DiscoveredMetadata` row -- declaration only, no value, seen by a maintainer reading a diagnostics attachment and by nobody else. So a battery vendor adding a field reached the user nowhere. `SpanPanelSnapshot.extension_properties` carries those properties with their values. `ExtensionSubject` names which modelled subject each hangs off, keyed by the same map keys the snapshot already uses, so a consumer resolves the device with a lookup it performs anyway. The field-level wire-to-snapshot mapping stays internal: the subject is one value per device and cannot drift, while exporting the map would freeze this adapter's internals as API. `ExtensionProperty` is deliberately not a `FieldMetadata`. `partition()` walks the metadata map, so a type that cannot enter it has no path into a payload that leaves the machine -- the diagnostics guarantee is structural rather than remembered, and the discovery rows keep flowing unchanged beside it. Read-only by construction: `settable` is carried for triage and no set topic exists to populate. These properties sit on the devices whose curated controls do real work, and a generic write path beside the EVSE ceiling refusal and the islanding translation would have neither. `addressed_rows()` is extracted so discovery and extension emission cannot disagree about what "unaddressed" means -- a disagreement would surface as an entity the diagnostics call ignored, or the reverse. Additive in both directions: the snapshot field defaults empty, so an older adapter degrades to today's behaviour with no new `SchemaAdapter` member, which would otherwise fail at discovery for every install whose adapter lags a release. schema-1 0.1.0b9 declares 3.0.0b12 as its floor for the reverse skew. --- CHANGELOG.md | 20 ++ packages/schema-1/CHANGELOG.md | 16 ++ packages/schema-1/pyproject.toml | 16 +- .../src/span_panel_api_schema_1/extension.py | 111 ++++++++ .../span_panel_api_schema_1/field_metadata.py | 46 +++- .../src/span_panel_api_schema_1/snapshot.py | 35 ++- pyproject.toml | 2 +- src/span_panel_api/__init__.py | 4 + src/span_panel_api/models.py | 123 +++++++++ tests/test_public_api_unchanged.py | 2 + tests/test_schema_one_discovery.py | 10 + tests/test_schema_one_extension.py | 260 ++++++++++++++++++ uv.lock | 8 +- 13 files changed, 627 insertions(+), 26 deletions(-) create mode 100644 packages/schema-1/src/span_panel_api_schema_1/extension.py create mode 100644 tests/test_schema_one_extension.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fa98287..bfd456a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0b12] + +### Added + +- **A vendor property on a device this library already models now reaches a consumer, instead of stopping at diagnostics.** The schema is explicitly vendor-extensible and adoption covered only half of that: a device type nothing here models is adopted with + its readings, while a new property on the BESS, a charger, a circuit or the panel became a `DiscoveredMetadata` row — a declaration, no value, visible only to a maintainer reading a diagnostics attachment. `SpanPanelSnapshot.extension_properties` carries + those properties with their values, so a consumer can render what the publisher published. +- **`ExtensionProperty` and `ExtensionSubject`.** The subject names which modelled snapshot subject a property hangs off — `battery`, `mid`, `pv`, `panel`, and `evse`/`circuit` with the instance key the snapshot's own maps use — so a consumer resolves the + device it belongs on with a lookup it already performs. What is _not_ exposed is the field-level mapping: the subject is one value per device and cannot drift, while the wire-property-to-snapshot-field map is the adapter's internal business and exporting + it would freeze it as API. + +### Notes + +- **The value never reaches diagnostics, and that is structural rather than remembered.** `ExtensionProperty` is deliberately not a `FieldMetadata`, so it cannot enter the map `partition()` walks and has no path into a payload that leaves the machine. The + discovery rows keep flowing unchanged: the same property appears in both surfaces on purpose, joined by its `{node}/{property}` path — a declaration for the maintainer, a reading for the user. +- **Read-only by construction.** An extension property carries `settable` for curation triage and no set topic, and there is no member a write path could be built from. These properties live on exactly the devices whose curated controls do real work — the + EVSE limit refuses a value above the commissioned ceiling, the islanding assertion translates `GRID` into `ON_GRID` — and a generic write beside them would have neither. +- **Additive in both skew directions.** The snapshot field defaults empty, so an older adapter degrades to the previous behaviour with no new `SchemaAdapter` member required — a required member would fail at _discovery_, taking down every install whose + adapter wheel lags the bootstrap by a release. The reverse skew is handled by `span-panel-api-schema-1` 0.1.0b9 declaring this release as its floor. + ## [3.0.0b11] Carries `span-panel-api-schema-1` 0.1.0b8, which restores `dominant_power_source` on a panel with no MID. No change in this distribution. diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index a25324a..f2ee7d8 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -7,6 +7,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is the parent/child device tree SPAN firmware `r202633+` publishes, identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version number. A release here means this parser changed, never that the panel did. +## [0.1.0b9] - 08/2026 + +Pre-release. **Requires `span-panel-api` 3.0.0b12 or newer** — the floor moved, because this parser now imports `ExtensionProperty` and `ExtensionSubject` and constructs a snapshot with `extension_properties`. + +### Added + +- **Vendor properties on modelled devices are emitted with their values.** Every property a modelled device declares that this adapter maps to no snapshot field — excluding `info` and `connection`, which resolve to the device card and the tree — now + arrives as an `ExtensionProperty` carrying its subject, its declaration and its retained value. A battery vendor hanging `battery-2/cell-temperature` off the BESS previously reached a consumer nowhere. +- **`node_has_curated_siblings`**, one bit per row: whether this adapter reads any _other_ property of the same node. A vendor extending `meter` is probably extending the meter, and that is the whole of what the bit says — which fields are read stays + internal. + +### Changed + +- **`addressed_rows()` is extracted from `build_discovery`**, so the discovery rows and the extension rows cannot disagree about what "unaddressed" means. A property counted as addressed by one and not the other would either appear as an entity the + diagnostics claim is ignored, or be reported ignored while a consumer renders it — each reading as a defect in whichever surface disagreed. + ## [0.1.0b8] - 08/2026 Pre-release. Requires `span-panel-api` 3.0.0b4 or newer — unchanged. diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index 2a7b86d..24f9f69 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api-schema-1" -version = "0.1.0b8" +version = "0.1.0b9" description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api" authors = [ {name = "SpanPanel"} @@ -9,12 +9,14 @@ readme = "README.md" license = "MIT" requires-python = ">=3.10,<4.0" dependencies = [ - # b3, not b2: this parser imports `SpanMidSnapshot`, which 3.0.0b2 does not - # define. A `>=3.0.0b2` floor lets a resolver pair this wheel with 3.0.0b2 and - # fail on import -- the precise hazard RELEASE.md warns about under "Releasing - # every distribution". schema-0 keeps its b2 floor; every name it imports is - # present there, checked rather than assumed. - "span-panel-api>=3.0.0b4,<4.0", + # b12, not b4: this parser imports `ExtensionProperty` and `ExtensionSubject` + # and constructs `SpanPanelSnapshot(extension_properties=...)`, none of which + # 3.0.0b11 defines. A lower floor lets a resolver pair this wheel with a + # bootstrap that fails at import -- the precise hazard RELEASE.md warns about + # under "Releasing every distribution", and the same reason the floor moved to + # b3 for `SpanMidSnapshot` before it. schema-0 keeps its own lower floor; + # every name it imports is present there, checked rather than assumed. + "span-panel-api>=3.0.0b12,<4.0", # Only this distribution depends on the eBus SDK. The bootstrap and # schema-0 stay clean, so a flat-panel install never pulls it in — which is # what bounds the release coupling this dependency introduces to panels on diff --git a/packages/schema-1/src/span_panel_api_schema_1/extension.py b/packages/schema-1/src/span_panel_api_schema_1/extension.py new file mode 100644 index 0000000..f427e7e --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/extension.py @@ -0,0 +1,111 @@ +"""Build ``ExtensionProperty`` records for properties on devices this adapter *does* model. + +The other half of vendor extensibility from :mod:`adoption`. That module handles +a device type nothing here models; this one handles a new property on a device +something here does -- a battery vendor hanging ``battery-2/cell-temperature`` +off the BESS. Until this existed the second case reached a consumer nowhere: it +became a discovery row and stopped at diagnostics, which only a maintainer +reading an attachment ever sees. + +**Values, like :mod:`adoption` and unlike :mod:`field_metadata`'s discovery +rows.** The same property is described by both surfaces on purpose, joined by +its ``{node}/{property}`` path: a declaration for the maintainer, a reading for +the user. The types are separate so that conflating them is a type error rather +than a leak, and `ExtensionProperty` is deliberately not a `FieldMetadata` -- +`partition()` walks the metadata map, so a value carried here has no path into a +payload that leaves the machine. + +**Read-only, structurally.** No set topic is built here and `ExtensionProperty` +has no member to put one in. A settable extension property is carried with +``settable=True`` for curation triage and still surfaces as a reading, because +these properties live on exactly the devices whose curated controls do real +safety work -- the EVSE limit refuses a value above the commissioned ceiling, +and the islanding assertion translates ``GRID`` into ``ON_GRID``. A generic +write path would sit beside both, on the same wire, with neither. + +**Unaddressed is asked once.** The set comes from +:func:`field_metadata.addressed_rows`, so this module and the discovery rows +cannot disagree about which properties this adapter reads. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from span_panel_api.models import ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE, ExtensionProperty, ExtensionSubject +from span_panel_api_schema_1.description import nodes, optional_str, properties +from span_panel_api_schema_1.field_metadata import is_addressed + +if TYPE_CHECKING: + from collections.abc import Sequence + + from ebus_sdk.homie import DiscoveredDevice + + +def build_extension_properties( + subjects: Sequence[tuple[DiscoveredDevice, ExtensionSubject]], + addressed: set[tuple[str, str, str]], +) -> tuple[ExtensionProperty, ...]: + """Every declared-but-unaddressed property of the modelled devices given. + + The caller supplies the pairing rather than this module deriving it, because + the subject key for a multi-instance kind is the snapshot's own map key -- + the circuit id, the harmonised EVSE key -- and those are decided while the + snapshot is being assembled. Deriving them a second time here would be a + second implementation of the same decision, free to drift from the first. + + Subject resolution is therefore indifferent to proxying by construction: a + device the snapshot builder sorted into a role arrives here already paired + with that role, whether the tree proxied it or not. The reference tree's own + MID arrives proxied as ``bess-mid`` and is paired with ``mid`` like any + other. + """ + found: list[ExtensionProperty] = [] + for device, subject in subjects: + declared = _declared_type(device) + if not declared: + continue + for node_id, node in nodes(device.description or {}).items(): + if node_id in (ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE): + continue + declarations = properties(node) + unaddressed = { + property_id: definition + for property_id, definition in declarations.items() + if not is_addressed(addressed, declared, node_id, property_id) + } + if not unaddressed: + continue + # True when the node carries at least one property this adapter does + # read. One bit rather than the node-to-field map: a vendor + # extending `meter` is probably extending the meter, and that is all + # a consumer can act on. Exporting which fields would freeze this + # adapter's internals as API for a signal the design ranks last. + has_curated_siblings = len(unaddressed) < len(declarations) + for property_id, definition in unaddressed.items(): + raw = device.get_property(node_id, property_id) + found.append( + ExtensionProperty( + subject=subject, + node_id=node_id, + property_id=property_id, + datatype=str(definition.get("datatype") or "string"), + unit=optional_str(definition.get("unit")), + format=optional_str(definition.get("format")), + settable=bool(definition.get("settable", False)), + value=None if raw is None else str(raw), + node_has_curated_siblings=has_curated_siblings, + ) + ) + return tuple(found) + + +def _declared_type(device: DiscoveredDevice) -> str: + """The device's declared ``$type``, or empty when it has not arrived yet. + + A device mid-discovery declares no type, which is a normal state rather than + a finding: it is skipped and picked up on a later snapshot, the same way + :func:`adoption.build_adopted_devices` skips it. + """ + description: dict[str, object] = device.description or {} + return str(description.get("type") or "") diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index c8283fc..573c1d7 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -510,6 +510,36 @@ def _lookup( """ +def addressed_rows(devices: list[DiscoveredDevice]) -> set[tuple[str, str, str]]: + """Every `(device type, node, property)` this adapter reads a snapshot field from. + + `_ADDRESSED` states the static rows; the loop adds the EVSE charge-limit + surface, which is resolved per device because firmware publishes the limit + and its ceiling under node and property names this adapter discovers rather + than knows. + + Extracted rather than inlined because two callers must agree exactly on what + "unaddressed" means: `build_discovery` reports those properties to a + maintainer, and `build_extension_properties` turns them into readings for a + user. A property counted as addressed by one and not the other would either + appear as an entity the diagnostics claim is ignored, or be reported ignored + while a consumer renders it — both of which read as defects in the surface + that disagrees. + """ + addressed = set(_ADDRESSED) + for device in devices: + evse_type = declared_type(device) + if not evse_type.startswith(TYPE_EVSE): + continue + surface = resolve_charge_limit(device) + if surface is None: + continue + for declaration in (surface.limit, surface.ceiling): + if declaration is not None: + addressed.add((evse_type, surface.node, declaration.property_id)) + return addressed + + def build_discovery(devices: list[DiscoveredDevice]) -> dict[str, DiscoveredMetadata]: """Metadata rows for every property this tree declares that nothing here reads. @@ -536,17 +566,7 @@ def build_discovery(devices: list[DiscoveredDevice]) -> dict[str, DiscoveredMeta unaddressed" there would describe the schema document and could not answer the question this exists to ask. """ - addressed = set(_ADDRESSED) - for device in devices: - evse_type = declared_type(device) - if not evse_type.startswith(TYPE_EVSE): - continue - surface = resolve_charge_limit(device) - if surface is None: - continue - for declaration in (surface.limit, surface.ceiling): - if declaration is not None: - addressed.add((evse_type, surface.node, declaration.property_id)) + addressed = addressed_rows(devices) declarations: dict[str, tuple[str | None, str]] = {} valued: set[str] = set() @@ -556,7 +576,7 @@ def build_discovery(devices: list[DiscoveredDevice]) -> dict[str, DiscoveredMeta continue for node_id, node in declared_nodes(device.description or {}).items(): for property_id, definition in declared_properties(node).items(): - if _addressed_by(addressed, device_type, node_id, property_id): + if is_addressed(addressed, device_type, node_id, property_id): continue path = discovery_path(_short_type(device_type), node_id, property_id) declarations.setdefault( @@ -579,7 +599,7 @@ def _short_type(device_type: str) -> str: return device_type -def _addressed_by(addressed: set[tuple[str, str, str]], device_type: str, node_id: str, property_id: str) -> bool: +def is_addressed(addressed: set[tuple[str, str, str]], device_type: str, node_id: str, property_id: str) -> bool: """Whether any addressed row covers this declaration, subtypes included. Carries `_lookup`'s subtype rule for the same reason it exists there: eBus diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index bc006e8..58111e5 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -11,7 +11,7 @@ import time from typing import TYPE_CHECKING -from span_panel_api.models import SpanPanelSnapshot +from span_panel_api.models import ExtensionSubject, SpanPanelSnapshot from span_panel_api_schema_1.adoption import build_adopted_devices from span_panel_api_schema_1.circuits import build_circuit from span_panel_api_schema_1.const import ( @@ -35,6 +35,8 @@ feed_circuit_ids, feed_connection_statuses, ) +from span_panel_api_schema_1.extension import build_extension_properties +from span_panel_api_schema_1.field_metadata import addressed_rows from span_panel_api_schema_1.panel import ( PanelFields, build_pcs, @@ -110,9 +112,15 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re } circuits = {} + # Paired with their snapshot key as they are built, for `extension.py`. The + # key a multi-instance subject carries has to be the one the snapshot map + # uses, and this loop is where a circuit's is decided -- deriving it a + # second time downstream would be a second implementation free to drift. + circuit_subjects: list[tuple[DiscoveredDevice, ExtensionSubject]] = [] for circuit in roles.circuits: snapshot = build_circuit(circuit, device_type=der_type_by_circuit.get(circuit.device_id, "circuit")) circuits[snapshot.circuit_id] = snapshot + circuit_subjects.append((circuit, ExtensionSubject(kind="circuit", instance_key=snapshot.circuit_id))) occupied = {tab for circuit in circuits.values() for tab in circuit.tabs} # Unoccupied positions are `total - occupied`, so this is only meaningful @@ -138,6 +146,30 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re inverters = [device for device in children if device_type(device) == TYPE_INVERTER] islanding = resolve_islanding_state(roles.mid, panel) + # Vendor extensions on devices this adapter *does* model. The pairing is + # built here, where each subject's snapshot key is already decided: the + # singletons key on nothing, the EVSEs on the harmonised key their snapshot + # map uses, the circuits on the ids collected above. Lugs pair to `panel` + # because that is the subject their fields land in. + evse_subjects = [ + (device, ExtensionSubject(kind="evse", instance_key=key)) for device, key in harmonised_evse_keys(roles.evse).items() + ] + singleton_subjects = [ + (device, ExtensionSubject(kind=kind)) + for kind, device in ( + ("panel", panel), + ("battery", roles.bess), + ("mid", roles.mid), + ("pv", roles.pv), + *(("panel", lugs) for lugs in roles.lugs), + ) + if device is not None + ] + extension_properties = build_extension_properties( + [*singleton_subjects, *evse_subjects, *circuit_subjects], + addressed_rows(children), + ) + return SpanPanelSnapshot( serial_number=fields.serial_number, firmware_version=fields.firmware_version, @@ -204,6 +236,7 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re # same `children` the roles were sorted from, so a type dropping out of # `TreeRoles` surfaces here rather than vanishing from both. adopted_devices=build_adopted_devices(children), + extension_properties=extension_properties, evse={ key: build_evse(device, feeds, node_id=key, feed_statuses=feed_statuses) for device, key in harmonised_evse_keys(roles.evse).items() diff --git a/pyproject.toml b/pyproject.toml index d52c57f..a10aeb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "3.0.0b11" +version = "3.0.0b12" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} diff --git a/src/span_panel_api/__init__.py b/src/span_panel_api/__init__.py index e9ce686..cac9a13 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -37,6 +37,8 @@ AdoptedDevice, AdoptedProperty, DiscoveredMetadata, + ExtensionProperty, + ExtensionSubject, FieldMetadata, HomieSchemaTypes, SpanBatterySnapshot, @@ -107,6 +109,8 @@ "ADOPTION_TOPOLOGY_NODE", "AdoptedDevice", "AdoptedProperty", + "ExtensionProperty", + "ExtensionSubject", # Snapshots "SpanBatterySnapshot", "SpanCircuitSnapshot", diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index e7ec62a..65540cc 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -776,6 +776,109 @@ class AdoptedDevice: """Everything outside `info` and `connection`, in declaration order.""" +@dataclass(frozen=True, slots=True) +class ExtensionSubject: + """Which modelled snapshot subject an extension property hangs off. + + The adapter already knows which wire device populated which snapshot subject + -- that mapping is how `battery.power_w` gets a value. This type exposes the + *subject* and never the mapping: a consumer needs "this belongs to the + battery", not "this is how `battery.*` is assembled". Exporting the + field-level map would freeze the adapter's internals as API; exporting the + subject cannot, because it is one value per device drawn from a closed set. + + Resolution is by declared `$type` and is indifferent to proxying: the + reference tree's own MID arrives proxied as `bess-mid` and still resolves to + `mid`. A proxied device of an *unmodelled* type resolves to no subject at all + and belongs to `AdoptedDevice`, which carries `parent` for that shape. + """ + + kind: str + """One of: `panel`, `battery`, `mid`, `pv`, `evse`, `circuit`.""" + + instance_key: str | None = None + """The snapshot map key for multi-instance kinds, `None` for the singletons. + + The EVSE's `node_id` and the circuit's `circuit_id` -- the same keys + `snapshot.evse` and `snapshot.circuits` use, so a consumer holding the + snapshot resolves the subject with a lookup it already performs. + """ + + +@dataclass(frozen=True, slots=True) +class ExtensionProperty: + """One property a *modelled* device declares that no snapshot field carries. + + The value-carrying counterpart to `DiscoveredMetadata`, and deliberately a + third type rather than either neighbour. A discovered row exists to be + forwarded in diagnostics -- payloads that leave the machine into issues and + forum posts -- so it carries no value by construction. An `AdoptedProperty` + belongs to a device nothing here models, and its `set_topic` scoping *is* a + write authorisation this type must not inherit. This one belongs to a device + the adapter does model, exists to reach a consumer as a reading, and is + read-only by construction: no set topic, and no member a write path could be + built from. + + **Not a `FieldMetadata`, and that is the diagnostics guarantee.** + `partition()` walks `build_field_metadata()`; this type rides + `build_snapshot()` instead, so there is no code path from here into + `SchemaFindings` or a diagnostics payload. The same wire property appears in + both surfaces on purpose -- as a declaration for the maintainer, as a value + for the user -- joined by the `{node}/{property}` path body. + + **Read-only is not a policy this type states, it is a shape it has.** A + settable extension property is carried with `settable=True` for curation + triage and still surfaces as a reading: a control on a modelled device would + sit beside curated controls that do real safety work (the EVSE limit refuses + a value above the commissioned ceiling; schema_1 translates `GRID` into + `ON_GRID`), and a generic write path would bypass both on the same wire. + """ + + subject: ExtensionSubject + """The curated device this property hangs off.""" + + node_id: str + """The Homie node, e.g. `battery-2`. Never `info` or `connection`.""" + + property_id: str + """The Homie property, e.g. `cell-temperature`.""" + + datatype: str + """The declared Homie datatype -- `float`, `integer`, `boolean`, `enum`, `string`.""" + + unit: str | None = None + """The declared unit, verbatim. `None` is normal for a `boolean` or an `enum`.""" + + format: str | None = None + """The declared `$format`: an option list for an `enum`, `min:max:step` for a number.""" + + settable: bool = False + """Declaration fact, carried for curation triage. + + Deliberately not paired with a set topic. See the read-only note above: the + absence of a write member is what makes the ruling structural rather than + remembered. + """ + + value: str | None = None + """The retained value as published, unparsed. `None` when declared and never valued.""" + + node_has_curated_siblings: bool = False + """Whether the adapter maps any *other* property of this node to a snapshot field. + + The one bit of the node-to-field mapping worth exporting: a vendor extending + `meter` is probably extending the meter. Stamped in one pass over knowledge + the adapter already holds, and it says nothing about *which* fields, so it + freezes no internals. A weak signal -- Homie nodes are organisational rather + than editorial -- and advisory only. + """ + + @property + def path(self) -> str: + """`{node}/{property}` -- how the capability catalogs spell it.""" + return f"{self.node_id}/{self.property_id}" + + @dataclass(frozen=True, slots=True) class SpanPanelSnapshot: """Complete panel state — single point-in-time view.""" @@ -938,6 +1041,26 @@ class SpanPanelSnapshot: field that defaults empty is additive and costs neither. """ + extension_properties: tuple[ExtensionProperty, ...] = () + """Properties *modelled* devices declare that no snapshot field carries. + + The other half of vendor extensibility from `adopted_devices` above: that + one covers a device type nothing models, this one a new property on a device + something does. Until this existed the second case reached a consumer + nowhere -- it became a `DiscoveredMetadata` row and stopped at diagnostics. + + Empty is deliberately ambiguous between "none declared" and "this adapter + predates the field", and a consumer must not try to tell them apart: the + older-wheel case is the normal partial-upgrade state, because the adapters + are separately published packages that version independently of this core. + A defaulted snapshot field rather than a protocol member for exactly the + reason `adopted_devices` gives -- a required member would fail at + *discovery*, taking down every install whose adapter lags by one release. + + schema_0 leaves it empty: flat has no device tree to find a declared-but + -unmapped property in, and panels upgrade to v1.0 and stay there. + """ + pcs: SpanPcsSnapshot | None = None """The enclosure's Power Control System, when it publishes a `pcs` node. v1.0 only. diff --git a/tests/test_public_api_unchanged.py b/tests/test_public_api_unchanged.py index 0fd1bf5..441b0bf 100644 --- a/tests/test_public_api_unchanged.py +++ b/tests/test_public_api_unchanged.py @@ -51,6 +51,8 @@ "ADOPTION_TOPOLOGY_NODE", "AdoptedDevice", "AdoptedProperty", + "ExtensionProperty", + "ExtensionSubject", "AdoptedControlProtocol", "is_discovery_path", # Snapshots diff --git a/tests/test_schema_one_discovery.py b/tests/test_schema_one_discovery.py index b1267c6..ee40cef 100644 --- a/tests/test_schema_one_discovery.py +++ b/tests/test_schema_one_discovery.py @@ -114,6 +114,16 @@ def _snapshot_fields(snapshot: SpanPanelSnapshot) -> dict[str, str]: fields: dict[str, str] = {} for field in dataclasses.fields(snapshot): value = getattr(snapshot, field.name) + if field.name == "extension_properties": + # Excluded, and the exclusion is the point rather than a convenience. + # This field carries every *unaddressed* declaration by construction, + # so republishing any property discovery reports would move it — and + # "does republishing this move a snapshot field" would answer yes for + # every discovered row, which is the question this oracle exists to + # ask. What the test still catches is the real defect: a discovered + # property that moves a *curated* field, i.e. one the mapper reads + # while the addressed set says it does not. + continue if field.name in {"circuits", "evse"}: for key, item in value.items(): _record(fields, f"{field.name}@{key}", item) diff --git a/tests/test_schema_one_extension.py b/tests/test_schema_one_extension.py new file mode 100644 index 0000000..00136eb --- /dev/null +++ b/tests/test_schema_one_extension.py @@ -0,0 +1,260 @@ +"""What the adapter emits as vendor extensions on devices it *does* model. + +`build_extension_properties` is the value-carrying twin of `build_discovery`: +the same declared-but-unaddressed question, asked of the same tree, answered for +a consumer that will render it rather than for a maintainer reading an +attachment. The two must agree exactly, so the first test here is the join — +every extension row has a discovery row and vice versa. + +The rest are structural, and they are structural on purpose. "Adopted values +never reach diagnostics" and "an extension property is read-only" are claims the +design makes about *shapes*, so they are asserted about shapes: a type that is +not a `FieldMetadata` cannot enter the metadata map that diagnostics is built +from, and a type with no set-topic member cannot grow a write path by someone +forgetting a rule. +""" + +from __future__ import annotations + +from collections.abc import Mapping +import json + +from ebus_sdk.homie import DiscoveredDevice +import pytest + +from span_panel_api.models import ( + ADOPTION_IDENTITY_NODE, + ADOPTION_TOPOLOGY_NODE, + ExtensionProperty, + ExtensionSubject, + FieldMetadata, + SpanPanelSnapshot, + discovery_path, +) +from span_panel_api_schema_1.extension import build_extension_properties +from span_panel_api_schema_1.field_metadata import addressed_rows, build_discovery +from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree +from span_panel_api_schema_1.snapshot import build_snapshot + +PANEL_DEVICE_ID = "example-40t-001" +"""The enclosure in the reference capture. Every other device is its child.""" + +Tree = dict[str, dict[str, str]] + + +def _tree() -> Tree: + return {device_id: dict(topics) for device_id, topics in parent_child_tree().items()} + + +def _snapshot(tree: Tree) -> SpanPanelSnapshot: + panel = device_from_topics(PANEL_DEVICE_ID, tree[PANEL_DEVICE_ID]) + children = [device_from_topics(device_id, topics) for device_id, topics in tree.items() if device_id != PANEL_DEVICE_ID] + return build_snapshot(panel, children) + + +def _devices(tree: Tree) -> list[DiscoveredDevice]: + return [device_from_topics(device_id, topics) for device_id, topics in tree.items()] + + +def _short_type(device_type: str) -> str: + return device_type.rsplit(".", 1)[-1] + + +def _declared_type(tree: Tree, device_id: str) -> str: + description: Mapping[str, object] = json.loads(tree[device_id]["$description"]) + return str(description.get("type") or "") + + +# --- the join with discovery ------------------------------------------------ + + +def test_every_extension_row_is_also_a_discovery_row() -> None: + """The two surfaces describe the same properties, joined by the wire path. + + A property in one and not the other is the defect this test exists for: an + entity a consumer renders while the diagnostics report it ignored, or a + property reported ignored while an entity shows its value. Both read as a + bug in whichever surface disagreed. + """ + tree = _tree() + snapshot = _snapshot(tree) + discovered = set(build_discovery(_devices(tree))) + + for row in snapshot.extension_properties: + # The discovery path is keyed by the *device type*, so rebuild it from + # the subject's device rather than from the subject kind, which is a + # snapshot concept. + assert any( + path.endswith(f"/{row.path}") for path in discovered + ), f"extension row {row.path} on {row.subject.kind} has no discovery row" + + +def test_no_extension_row_is_addressed() -> None: + """An addressed property has a snapshot field; surfacing it twice is the bug.""" + tree = _tree() + addressed = addressed_rows(_devices(tree)) + for row in _snapshot(tree).extension_properties: + assert not any( + node == row.node_id and prop == row.property_id for _type, node, prop in addressed + ), f"{row.path} is addressed and must not be emitted as an extension" + + +# --- structure: the diagnostics and read-only guarantees -------------------- + + +def test_extension_property_is_not_field_metadata() -> None: + """The diagnostics guarantee, asserted as a shape rather than as a rule. + + `partition()` walks `build_field_metadata()`; a type that cannot enter that + map has no path into a payload that leaves the machine. + """ + row = ExtensionProperty( + subject=ExtensionSubject(kind="battery"), + node_id="battery-2", + property_id="cell-temperature", + datatype="float", + ) + assert not isinstance(row, FieldMetadata) + + +def test_extension_property_has_no_write_surface() -> None: + """No set topic, and no member one could be put in. + + A literal `hasattr` check, because the point is to fail the change that adds + one rather than to describe today's fields. + """ + row = ExtensionProperty( + subject=ExtensionSubject(kind="evse", instance_key="acme-001"), + node_id="acme", + property_id="charge-limit", + datatype="float", + settable=True, + ) + assert not hasattr(row, "set_topic") + assert row.settable is True, "settable is carried for triage, and still carries no write path" + + +def test_identity_and_topology_nodes_are_never_extensions() -> None: + """`info` and `connection` resolve to the device card and the tree. + + Excluded by node, as `adoption._readings` excludes them, because the + catalogs carry no marker for "this string is a device reference" and a name + list goes stale silently. + """ + for row in _snapshot(_tree()).extension_properties: + assert row.node_id not in (ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE) + + +# --- emission against a synthetic vendor extension -------------------------- + + +VENDOR_NODE = "battery-2" + + +def _with_vendor_extension(tree: Tree, device_id: str) -> Tree: + """Add an Acme pack node to one device's description, with one retained value.""" + mutated = {other: dict(topics) for other, topics in tree.items()} + description = json.loads(mutated[device_id]["$description"]) + description["nodes"][VENDOR_NODE] = { + "name": VENDOR_NODE, + "type": "energy.ebus.capability.vendor.acme.pack", + "properties": { + "cell-temperature": {"name": "Cell temperature", "datatype": "float", "unit": "°C"}, + "pack-enabled": {"name": "Pack enabled", "datatype": "boolean", "settable": True}, + }, + } + mutated[device_id]["$description"] = json.dumps(description) + mutated[device_id][f"{VENDOR_NODE}/cell-temperature"] = "31.4" + return mutated + + +def _bess_device_id(tree: Tree) -> str: + for device_id in tree: + if _declared_type(tree, device_id).endswith(".bess"): + return device_id + pytest.skip("reference tree carries no BESS") + + +def test_a_vendor_node_on_a_modelled_device_becomes_extension_rows() -> None: + """The whole point: a property hung off the BESS reaches the snapshot.""" + tree = _tree() + device_id = _bess_device_id(tree) + snapshot = _snapshot(_with_vendor_extension(tree, device_id)) + + rows = {row.path: row for row in snapshot.extension_properties} + assert f"{VENDOR_NODE}/cell-temperature" in rows + assert f"{VENDOR_NODE}/pack-enabled" in rows + + temperature = rows[f"{VENDOR_NODE}/cell-temperature"] + assert temperature.subject.kind == "battery" + assert temperature.subject.instance_key is None + assert temperature.datatype == "float" + assert temperature.unit == "°C" + assert temperature.value == "31.4" + # Declared and never valued is distinguishable from valued: `None` rather + # than an invented default, so a consumer can tell "nothing has arrived". + assert rows[f"{VENDOR_NODE}/pack-enabled"].value is None + assert rows[f"{VENDOR_NODE}/pack-enabled"].settable is True + + +def test_a_wholly_vendor_node_has_no_curated_siblings() -> None: + """The one exported bit of the node-to-field map, on a node with none.""" + tree = _tree() + snapshot = _snapshot(_with_vendor_extension(tree, _bess_device_id(tree))) + rows = [row for row in snapshot.extension_properties if row.node_id == VENDOR_NODE] + assert rows + assert all(row.node_has_curated_siblings is False for row in rows) + + +def test_an_extension_to_a_curated_node_reports_curated_siblings() -> None: + """A vendor extending `meter` is extending something this adapter reads.""" + tree = _tree() + device_id = _bess_device_id(tree) + mutated = {other: dict(topics) for other, topics in tree.items()} + description = json.loads(mutated[device_id]["$description"]) + meter = description["nodes"].get("meter") + if meter is None: + pytest.skip("reference BESS declares no meter node") + meter["properties"]["acme-cell-balance"] = {"name": "Cell balance", "datatype": "float", "unit": "%"} + mutated[device_id]["$description"] = json.dumps(description) + + rows = [row for row in _snapshot(mutated).extension_properties if row.property_id == "acme-cell-balance"] + assert len(rows) == 1 + assert rows[0].node_has_curated_siblings is True + # `%` is deliberately unrankable: the consumer maps no device class for it. + assert rows[0].unit == "%" + + +def test_an_undeclared_device_is_skipped_rather_than_emitted() -> None: + """A device mid-discovery declares no type; that is a state, not a finding.""" + tree = _tree() + device_id = _bess_device_id(tree) + mutated = {other: dict(topics) for other, topics in tree.items()} + description = json.loads(mutated[device_id]["$description"]) + description.pop("type", None) + mutated[device_id]["$description"] = json.dumps(description) + + subjects = [(device_from_topics(device_id, mutated[device_id]), ExtensionSubject(kind="battery"))] + assert build_extension_properties(subjects, addressed_rows(_devices(mutated))) == () + + +def test_the_reference_tree_alone_emits_nothing_addressed() -> None: + """A sanity floor: every row the untouched tree emits is genuinely unread.""" + snapshot = _snapshot(_tree()) + addressed = addressed_rows(_devices(_tree())) + for row in snapshot.extension_properties: + assert not any(node == row.node_id and prop == row.property_id for _t, node, prop in addressed) + + +def test_discovery_path_joins_the_two_surfaces() -> None: + """The documented join key actually joins.""" + tree = _tree() + device_id = _bess_device_id(tree) + mutated = _with_vendor_extension(tree, device_id) + discovered = set(build_discovery(_devices(mutated))) + expected = discovery_path(_short_type(_declared_type(mutated, device_id)), VENDOR_NODE, "cell-temperature") + assert expected in discovered + + rows = {row.path for row in _snapshot(mutated).extension_properties} + assert f"{VENDOR_NODE}/cell-temperature" in rows + assert expected.endswith(f"/{VENDOR_NODE}/cell-temperature") diff --git a/uv.lock b/uv.lock index b44a5d7..cf936e2 100644 --- a/uv.lock +++ b/uv.lock @@ -519,7 +519,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -607,7 +607,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.12'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -1323,7 +1323,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "3.0.0b11" +version = "3.0.0b12" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -1402,7 +1402,7 @@ requires-dist = [{ name = "span-panel-api", editable = "." }] [[package]] name = "span-panel-api-schema-1" -version = "0.1.0b8" +version = "0.1.0b9" source = { editable = "packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From 8623107178b2d1638c5afd04b115d5f2c3b743f6 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:23:58 -0700 Subject: [PATCH 114/115] fix(schema-1): the two lugs devices are two extension subjects 0.1.0b9 paired both with `kind="panel"` and no instance key, reasoning that their curated fields land in the panel snapshot. But a subject is an identity, and a consumer keys an entity on `(kind, instance_key, node/property)`: two lugs devices declaring the same vendor property produced one identity for two readings, so whichever sorted first won and the other was dropped. Identical firmware on both lugs makes that the expected case rather than a coincidence -- a vendor extension on one is a vendor extension on both. Keyed on `info/direction` for the reason `find_lugs` documents: the reference tree's ids are the simulator's naming, while the direction property is what the schema defines. A lugs device declaring no direction is left unpaired rather than keyed on something unstable; its properties stay in discovery, which is where an unidentifiable device belongs. --- CHANGELOG.md | 5 +++ packages/schema-1/CHANGELOG.md | 12 +++++- packages/schema-1/pyproject.toml | 2 +- .../src/span_panel_api_schema_1/snapshot.py | 23 ++++++++-- pyproject.toml | 2 +- src/span_panel_api/models.py | 9 +++- tests/test_schema_one_extension.py | 43 +++++++++++++++++++ uv.lock | 4 +- 8 files changed, 90 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfd456a..fe983e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0b13] + +Carries `span-panel-api-schema-1` 0.1.0b10, which gives the two lugs devices their own extension subject. No behaviour change in this distribution: `ExtensionSubject.kind` documents `lugs` alongside the kinds it already accepted, which the type never +enumerated in code. + ## [3.0.0b12] ### Added diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index f2ee7d8..c62614c 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -7,7 +7,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is the parent/child device tree SPAN firmware `r202633+` publishes, identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version number. A release here means this parser changed, never that the panel did. -## [0.1.0b9] - 08/2026 +## [0.1.0b10] - 08/2026 + +Pre-release. Requires `span-panel-api` 3.0.0b12 or newer — unchanged. + +### Fixed + +- **The two lugs devices are two extension subjects, not one.** 0.1.0b9 paired both with `ExtensionSubject(kind="panel")` and no instance key, on the reasoning that their curated fields land in the panel snapshot. But a subject is an _identity_: a consumer + keys an entity on `(kind, instance_key, node/property)`, so two lugs devices declaring the same vendor property produced one identity for two readings — whichever sorted first won, and the other was dropped. Identical firmware on both lugs makes that the + expected case rather than a coincidence, not something a vendor would have to do oddly to hit. They are now `kind="lugs"` with `upstream`/`downstream` as the instance key, matched on `info/direction` for the reason `find_lugs` documents: the reference + tree's ids are the simulator's naming, and the direction property is what the schema defines. A lugs device declaring no direction is left unpaired rather than keyed on something unstable — its properties stay in discovery, which is where an + unidentifiable device belongs. Pre-release. **Requires `span-panel-api` 3.0.0b12 or newer** — the floor moved, because this parser now imports `ExtensionProperty` and `ExtensionSubject` and constructs a snapshot with `extension_properties`. diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index 24f9f69..42b35e1 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api-schema-1" -version = "0.1.0b9" +version = "0.1.0b10" description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api" authors = [ {name = "SpanPanel"} diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index 58111e5..9b56e07 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -149,11 +149,27 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re # Vendor extensions on devices this adapter *does* model. The pairing is # built here, where each subject's snapshot key is already decided: the # singletons key on nothing, the EVSEs on the harmonised key their snapshot - # map uses, the circuits on the ids collected above. Lugs pair to `panel` - # because that is the subject their fields land in. + # map uses, the circuits on the ids collected above. evse_subjects = [ (device, ExtensionSubject(kind="evse", instance_key=key)) for device, key in harmonised_evse_keys(roles.evse).items() ] + # **Lugs are their own subject, keyed by direction.** They were `panel` in + # 0.1.0b9, which made the subject non-unique: a consumer keys an identity on + # `(kind, instance_key, node/property)`, and the two lugs devices run the + # same firmware, so a vendor extension on one is the *expected* case of a + # vendor extension on both -- two wire addresses collapsing onto one + # identity, with whichever sorted first winning. `find_lugs` matches + # direction rather than device id for the reason it documents, and the same + # reasoning keys the subject: ids in the reference tree are the simulator's + # naming, while `info/direction` is what the schema defines. A lugs device + # declaring no direction is left unpaired rather than keyed on something + # unstable -- its properties stay in discovery, which is where an + # unidentifiable device belongs. + lugs_subjects = [ + (device, ExtensionSubject(kind="lugs", instance_key=key)) + for key, device in (("upstream", upstream), ("downstream", downstream)) + if device is not None + ] singleton_subjects = [ (device, ExtensionSubject(kind=kind)) for kind, device in ( @@ -161,12 +177,11 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re ("battery", roles.bess), ("mid", roles.mid), ("pv", roles.pv), - *(("panel", lugs) for lugs in roles.lugs), ) if device is not None ] extension_properties = build_extension_properties( - [*singleton_subjects, *evse_subjects, *circuit_subjects], + [*singleton_subjects, *lugs_subjects, *evse_subjects, *circuit_subjects], addressed_rows(children), ) diff --git a/pyproject.toml b/pyproject.toml index a10aeb3..c0e7595 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "3.0.0b12" +version = "3.0.0b13" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 65540cc..52f2c8c 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -794,7 +794,14 @@ class ExtensionSubject: """ kind: str - """One of: `panel`, `battery`, `mid`, `pv`, `evse`, `circuit`.""" + """One of: `panel`, `lugs`, `battery`, `mid`, `pv`, `evse`, `circuit`. + + `lugs` is separate from `panel` although its curated fields land in the panel + snapshot, because a subject is an identity and the two lugs devices are two + devices: they run the same firmware, so a vendor extension on one is the + expected case of the same extension on both, and folding them into `panel` + made two wire addresses one identity. + """ instance_key: str | None = None """The snapshot map key for multi-instance kinds, `None` for the singletons. diff --git a/tests/test_schema_one_extension.py b/tests/test_schema_one_extension.py index 00136eb..dac7f2e 100644 --- a/tests/test_schema_one_extension.py +++ b/tests/test_schema_one_extension.py @@ -258,3 +258,46 @@ def test_discovery_path_joins_the_two_surfaces() -> None: rows = {row.path for row in _snapshot(mutated).extension_properties} assert f"{VENDOR_NODE}/cell-temperature" in rows assert expected.endswith(f"/{VENDOR_NODE}/cell-temperature") + + +def test_the_two_lugs_devices_are_two_subjects() -> None: + """Identical firmware on both lugs is what made one subject a collision. + + A vendor extension on the upstream lugs is the expected case of the same + extension on the downstream lugs, so folding both into `panel` gave two wire + addresses one identity -- a consumer keying on + `(kind, instance_key, node/property)` would mint one id for two readings and + show whichever sorted first. + """ + tree = _tree() + lugs = [ + device_id + for device_id in tree + if ".lugs" in _declared_type(tree, device_id) or _declared_type(tree, device_id).endswith("lugs") + ] + if len(lugs) < 2: + pytest.skip("reference tree carries fewer than two lugs devices") + + mutated = {other: dict(topics) for other, topics in tree.items()} + for device_id, value in zip(lugs, ("1.5", "99.9"), strict=False): + description = json.loads(mutated[device_id]["$description"]) + description["nodes"]["acme"] = { + "name": "acme", + "type": "energy.ebus.capability.vendor.acme.balance", + "properties": {"phase-balance": {"name": "Phase balance", "datatype": "float", "unit": "%"}}, + } + mutated[device_id]["$description"] = json.dumps(description) + mutated[device_id]["acme/phase-balance"] = value + + rows = [row for row in _snapshot(mutated).extension_properties if row.path == "acme/phase-balance"] + assert len(rows) == 2 + assert all(row.subject.kind == "lugs" for row in rows) + assert {row.subject.instance_key for row in rows} == {"upstream", "downstream"} + # Distinct identities carrying distinct readings, which is the point. + assert {row.value for row in rows} == {"1.5", "99.9"} + + +def test_every_subject_identity_is_unique_per_property() -> None: + """No two rows may share `(kind, instance_key, path)` -- that tuple is the identity.""" + identities = [(row.subject.kind, row.subject.instance_key, row.path) for row in _snapshot(_tree()).extension_properties] + assert len(identities) == len(set(identities)) diff --git a/uv.lock b/uv.lock index cf936e2..9e254c7 100644 --- a/uv.lock +++ b/uv.lock @@ -1323,7 +1323,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "3.0.0b12" +version = "3.0.0b13" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -1402,7 +1402,7 @@ requires-dist = [{ name = "span-panel-api", editable = "." }] [[package]] name = "span-panel-api-schema-1" -version = "0.1.0b9" +version = "0.1.0b10" source = { editable = "packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From 76123a58214e66929ac963d542bd6cd3ad612ce0 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:46:20 -0700 Subject: [PATCH 115/115] release: 3.0.0, with both adapters at 1.0.0 First public release of the split: span-panel-api 3.0.0, span-panel-api-schema-0 1.0.0 and span-panel-api-schema-1 1.0.0. Every dependency floor between them now names a stable version rather than the prerelease it tracked during development -- a specifier naming a prerelease is pip's own signal that prereleases are acceptable for that requirement, so the old floors would have left a released install willing to resolve a future beta of its sibling unasked. Changelogs carry public versions only. Every beta heading is folded into the entry for the version it was working towards, described against the last public release rather than against the beta before it, and a fix that only repaired an earlier beta is gone entirely -- from the point of view of somebody upgrading between released versions it never happened. That collapses thirteen bootstrap betas into one 3.0.0 entry and drops the b8/b9/b10 retry-widening narrative, the _charge_positive rename and the b1-to-b3 discover_adapters churn, none of which a reader upgrading from 2.6.4 has any use for. RELEASE.md states the rule so the next beta does not reintroduce the old shape. Python floor raised to 3.14, matching the only thing that consumes this. Home Assistant requires >=3.14.2 from 2026.3, and the SPAN integration requires HA 2026.8 or newer, so every install reaching this code is on 3.14 whatever we declare. The old >=3.10 was never true in either direction: tests/test_packaging imports tomllib, stdlib only from 3.11, and 3.10 replaces a Protocol's __init__ with (*args, **kwargs), so SchemaAdapter's declared constructor signature is not introspectable there and the check that stops two independently-versioned wheels disagreeing about construction had nothing to read. Floor and CI matrix are now the same version, which is the only arrangement where a green run proves the declared range. mypy's python_version follows the floor for the same reason. Raising the target let ruff apply what it unlocks: asyncio.TimeoutError is the builtin TimeoutError from 3.11, Generator[T, None, None] is Generator[T] from 3.13, and typing.TypeAlias is superseded by the type keyword. The two converted aliases are annotation-only under `from __future__ import annotations`, so nothing resolves them at runtime. The eBus SDK ceiling is now a tested claim. schema-1 declared >=0.19,<0.24 while the lock pinned 0.21.0 and 0.23.1 was current -- and the lock does not ship, so every fresh install resolved the version CI had never run. Upgraded to 0.23.1 (ebus-mqtt-client 0.5.0 with it) and the suite is green, which makes the comment above the bound true again. Documentation corrected against what the code actually does. The README still described HomiePropertyAccumulator, HomieLifecycle and HomieDeviceConsumer as this package's own layers after they moved to schema-0, listed mqtt/accumulator and mqtt/homie in a tree where neither exists, claimed three protocols while showing four, credited a simulation engine removed in 2.3.0, and predated the adapter errors, the widened SpanPanelServerError and the product_name retirement. It gains a section on the hot-loading model, which is the thing a consumer most needs and the thing nothing explained. schema-1's README announced "Status: incomplete -- this distribution does not yet register a schema_1 adapter", which has been false since its first beta and would have shipped on the 1.0.0 page. Also adds span_panel_api_schema_1 to ruff's known-first-party, which the comment directly above it exists to prevent being missed and did not, and adds schema-1 to the coverage measured in CI, which the pre-commit gate covered and the uploaded report did not. --- .github/workflows/ci.yml | 6 + CHANGELOG.md | 392 ++++----------- DEVELOPMENT.md | 23 +- README.md | 146 ++++-- RELEASE.md | 34 +- conftest.py | 2 +- packages/schema-0/CHANGELOG.md | 70 +-- packages/schema-0/README.md | 7 +- packages/schema-0/pyproject.toml | 9 +- packages/schema-1/CHANGELOG.md | 235 +++------ packages/schema-1/README.md | 51 +- packages/schema-1/pyproject.toml | 21 +- .../reference_payloads/__init__.py | 3 +- .../src/span_panel_api_schema_1/snapshot.py | 4 +- pyproject.toml | 43 +- src/span_panel_api/models.py | 3 +- src/span_panel_api/mqtt/client.py | 2 +- src/span_panel_api/mqtt/connection.py | 2 +- tests/test_adapters_discovery.py | 4 +- tests/test_schema_one_against_simulator.py | 4 +- uv.lock | 476 +----------------- 21 files changed, 489 insertions(+), 1048 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67ce9cb..8647d9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,11 @@ jobs: runs-on: ubuntu-latest strategy: matrix: + # One entry because `requires-python` is `>=3.14`: the declared floor and + # the version tested are the same, which is the only arrangement where a + # green run actually proves the range. Widen `requires-python` and this + # list has to grow with it -- a floor no job runs is a claim, not a + # guarantee. python-version: ["3.14"] steps: @@ -64,6 +69,7 @@ jobs: uv run pytest tests/ -v -rs \ --cov=src/span_panel_api \ --cov=packages/schema-0/src/span_panel_api_schema_0 \ + --cov=packages/schema-1/src/span_panel_api_schema_1 \ --cov-report=xml --cov-report=term-missing diff --git a/CHANGELOG.md b/CHANGELOG.md index fe983e8..3643167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,316 +4,138 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [3.0.0b13] +Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the **last public release**, never against the beta before it. What one +beta corrected in an earlier beta does not appear at all: from the point of view of somebody upgrading between released versions, it never happened. -Carries `span-panel-api-schema-1` 0.1.0b10, which gives the two lugs devices their own extension subject. No behaviour change in this distribution: `ExtensionSubject.kind` documents `lugs` alongside the kinds it already accepted, which the type never -enumerated in code. +## [3.0.0] -## [3.0.0b12] +`span-panel-api` becomes a transport and a dispatcher that contains **no parser**. Wire formats ship as separate distributions and register themselves through the `span_panel_api.schema_adapters` entry-point group, so support for a new panel schema arrives +by installing a package rather than by upgrading the transport. -### Added - -- **A vendor property on a device this library already models now reaches a consumer, instead of stopping at diagnostics.** The schema is explicitly vendor-extensible and adoption covered only half of that: a device type nothing here models is adopted with - its readings, while a new property on the BESS, a charger, a circuit or the panel became a `DiscoveredMetadata` row — a declaration, no value, visible only to a maintainer reading a diagnostics attachment. `SpanPanelSnapshot.extension_properties` carries - those properties with their values, so a consumer can render what the publisher published. -- **`ExtensionProperty` and `ExtensionSubject`.** The subject names which modelled snapshot subject a property hangs off — `battery`, `mid`, `pv`, `panel`, and `evse`/`circuit` with the instance key the snapshot's own maps use — so a consumer resolves the - device it belongs on with a lookup it already performs. What is _not_ exposed is the field-level mapping: the subject is one value per device and cannot drift, while the wire-property-to-snapshot-field map is the adapter's internal business and exporting - it would freeze it as API. +### Removed -### Notes +- **BREAKING: `span-panel-api` no longer contains a parser.** Installing it alone gives a client that connects and then raises `SpanPanelAdapterMissingError`. A parser is an install: -- **The value never reaches diagnostics, and that is structural rather than remembered.** `ExtensionProperty` is deliberately not a `FieldMetadata`, so it cannot enter the map `partition()` walks and has no path into a payload that leaves the machine. The - discovery rows keep flowing unchanged: the same property appears in both surfaces on purpose, joined by its `{node}/{property}` path — a declaration for the maintainer, a reading for the user. -- **Read-only by construction.** An extension property carries `settable` for curation triage and no set topic, and there is no member a write path could be built from. These properties live on exactly the devices whose curated controls do real work — the - EVSE limit refuses a value above the commissioned ceiling, the islanding assertion translates `GRID` into `ON_GRID` — and a generic write beside them would have neither. -- **Additive in both skew directions.** The snapshot field defaults empty, so an older adapter degrades to the previous behaviour with no new `SchemaAdapter` member required — a required member would fail at _discovery_, taking down every install whose - adapter wheel lags the bootstrap by a release. The reverse skew is handled by `span-panel-api-schema-1` 0.1.0b9 declaring this release as its floor. + ```console + # flat-schema panels, firmware r202603-r202627 + pip install "span-panel-api[schema-0]" -## [3.0.0b11] + # parent/child panels, firmware r202633+ + pip install "span-panel-api[schema-1]" + ``` -Carries `span-panel-api-schema-1` 0.1.0b8, which restores `dominant_power_source` on a panel with no MID. No change in this distribution. + The adapter distributions can equally be named directly; the extras exist because the dependency arrow runs the other way — an adapter declares a floor on the bootstrap, the bootstrap requires no adapter — so upgrading the bootstrap alone would otherwise + leave a stale adapter wheel that discovery then rejects, with pip reporting success. The bootstrap never imports an adapter, and supporting a new panel schema on an existing install is an install rather than an upgrade. -## [3.0.0b10] +- **BREAKING: `HomieLifecycle`, `HomiePropertyAccumulator` and `HomieDeviceConsumer` are no longer exported** from `span_panel_api` or `span_panel_api.mqtt`. All three are flat-schema-specific rather than Homie-convention-level: the accumulator filters + every topic against a single device's prefix and stores `node → prop`, which drops nearly every message under the parent/child model; `HomieLifecycle`'s members are not Homie 5 `$state` values but a consumer-side progression encoding "one description + received ⇒ ready", which is the flat readiness model. They now live in `span_panel_api_schema_0`. +- **Removed dead constants** `DEVICE_TOPIC_FMT`, `STATE_TOPIC_FMT`, `DESCRIPTION_TOPIC_FMT`, `PROPERTY_TOPIC_FMT` (unreferenced) and `TYPE_PCS` (a real schema type this library does not consume). ### Changed -- **The wait for a panel to finish rebooting no longer gives up.** It used to stop after a fixed number of attempts, and that bound was wrong twice for the same reason: it was sized against a reboot somebody had measured, and the next reboot was not that - reboot. Giving up has nothing to recommend it — the only things that start another attempt are the reconnect edge and the panel republishing its data-model version, and a panel that finishes booting after the wait expired produces neither, so running out - of attempts means stranded until somebody reloads by hand. It now waits as long as the panel takes. -- **Waiting costs nothing you were relying on.** Energy sensors already hold their last reading through an outage on their own grace period — fifteen minutes by default, configurable — which exists precisely so a gap does not become an `unknown` and a - statistics spike. That is untouched by how long this waits, and it was the only thing that would have justified a deadline. What is left is one request every thirty seconds to a device on your own network. -- **The retry interval settles at thirty seconds rather than growing.** Backing off without a ceiling would mean a panel that took a while to return was then ignored for longer than it took. The gap goes 1, 2, 4, 8, 16, 30 and stays there, so once your - panel is answering it is noticed within half a minute however long the wait has already run. - -### Fixed - -- **Four more ways a booting panel answers now count as "not ready" rather than as a hard failure.** b8 and b9 covered the 502 that a live upgrade produced; review found the fix had covered the observed shape rather than the class. A panel resetting its - listener mid-request raises `ReadError` or `WriteError`, a proxy that dies mid-request raises `RemoteProtocolError`, and a panel part-way through starting can answer `200` with a truncated or empty body. All four escaped untranslated, skipped the retry - entirely, and stranded the parser exactly as the 502 did. Transport failures are now `SpanPanelConnectionError` and an unusable body is `SpanPanelServerError`. -- **The last retry attempt happens after the reboot it is sized for.** The window ended with a sleep that no attempt followed: it read 241 seconds while the final request went out at 211, so a panel ready at 220 was still abandoned. The loop no longer - sleeps after its final attempt — which also stopped it holding the in-flight guard, and the warning, for a pointless extra backoff — and the last request now lands at 241 seconds. The test asserts that offset instead of summing the sleeps, which was - restating the implementation's own off-by-one. -- **The give-up warning no longer promises a recovery that cannot arrive.** It said data would read as missing "until the next reconnect". The triggers are the reconnect edge and the retained `data-model-version` message, and a panel that finishes booting - produces neither again, so exhausting the window means stuck until a reload. It now says so. - -## [3.0.0b9] - -### Fixed - -- **The retry window for a rebooting panel is actually widened this time.** b8 taught the schema fetch to treat a `502` as "not ready yet" but shipped with the old five attempts capped at eight seconds — about twenty-three seconds against a panel observed - taking four minutes to come back, still answering 502 when the broker returned. The widening was written, lost to a failed edit in the same change, and shipped without it; nothing failed, because catching the 502 and giving up early looks exactly like - working. Now twelve attempts backing off to thirty seconds, a little over four minutes, and pinned by a test that asserts the total window outlasts the observed reboot rather than checking the constants individually. - -## [3.0.0b8] - -### Fixed +- **BREAKING — DER identity speaks the parent/child vocabulary on every device class.** `model` is the human designation and `part_number` the SKU, on `battery`, `evse` and `pv` alike. `product_name` is retired on all three. Flat is the inconsistent side: + it puts the SKU in `bess/model` and in `evse/part-number`, the same concept under two names, and gives PV neither. Mirroring that would have permanently encoded flat's irregularity in the snapshot, so `schema_0` translates flat into the normalised shape + instead. Measured: every EVSE identity field reads identically on both adapters, so for that device class identity stops being a migration delta at all. **`battery.model` changes value for existing flat users at this upgrade** — it gains the designation + where it carried the SKU. That is the deliberate trade: a change scheduled in a library release beats the same change arriving unplanned during a firmware upgrade a user did not choose the timing of. +- **Consumers reading `product_name` must move to `model` in the same release.** The Home Assistant integration builds its device-registry model from it; left unchanged, device cards go blank. +- **Dispatch refuses an unreadable `data-model-version` instead of assuming flat.** Absence still means the flat schema — that is a real signal, since the property was introduced by the firmware that introduced parent/child. A value whose major _can_ be + read but whose form is non-canonical (`1`, `1.0-beta`) dispatches on that major and logs the deviation. A value with no extractable major raises `SpanPanelSchemaVersionError`. Previously all three fell through to the flat parser, which does not fail — it + produces plausible but wrong power and energy figures. +- **`get_homie_schema()` tells "not ready yet" apart from "will not fix itself".** Any 5xx raises `SpanPanelServerError`, a transport failure raises `SpanPanelConnectionError`, and a `200` carrying a truncated or empty body raises `SpanPanelServerError` + rather than surfacing as a parse error. A booting panel brings its network stack and reverse proxy up before the application behind them, so it answers rather than refuses; the distinction is what lets a caller retry that and not retry a 4xx. -- **A panel answering `502` while it reboots no longer costs the automatic reload.** When a panel upgrades its firmware it drops MQTT, comes back, and serves HTTP a little later — and a booting device brings its network stack and reverse proxy up before - the application behind them, so the schema fetch is answered with `502` rather than refused. The retry that exists for exactly this handled "cannot reach" and "timed out" but not "answered, with 502", so the first attempt raised straight out of the loop, - out of the fire-and-forget task that called it, and the parser was never swapped. Caught on two Home Assistant instances watching one panel through the same live upgrade: both logged `Task exception was never retrieved`, both stayed on the old parser, - and neither recovered without a manual reload. `get_homie_schema` now raises `SpanPanelServerError` for any 5xx — "not ready yet", distinct from a 4xx that will not fix itself — and the retry treats it as retryable. -- **The wait is now the length of a real reboot.** Five attempts backing off to 8s gave up after about 23 seconds. The observed upgrade took four minutes from MQTT dropping to the broker returning, with HTTP still answering 502 at that point. Twelve - attempts backing off to 30s covers it. -- **Nothing escapes the redispatch task any more.** An unexpected failure there used to surface as a bare `Task exception was never retrieved` while the parser silently stayed on the old generation — the failure the redispatch exists to prevent, reached by - another route. It is now logged at ERROR naming the consequence and the remedy, because a reload is the user's only move and nothing else was going to tell them. +### Added -## [3.0.0b7] +#### Adapter architecture + +- **The `SchemaAdapter` protocol, and `ADAPTER_CONTRACT` alongside it.** Member presence is not the whole contract — a Protocol cannot express signatures at runtime, so an adapter carrying every required name and the wrong `__init__` arity would pass + discovery and fail much later inside the transport, as a bare `TypeError` about an argument count. Every adapter declares `ADAPTER_CONTRACT` as a **literal** and discovery rejects anything that does not match this package's `ADAPTER_CONTRACT_VERSION`; a + value read from the installed bootstrap would agree with every bootstrap, which is the disagreement being looked for. The required-member set is derived from every public member the protocol declares, not only the callable ones. +- **`installed_adapter_keys()` and `SpanMqttClient.installed_adapters`.** Enumeration reads distribution metadata only; an adapter is imported the first time a panel asks for that key. A flat panel therefore never imports `schema_1`, and with it never + imports the eBus SDK or jsonschema, for a parser it would not call. The async paths run both in a thread, and resolution stays cached per key, which is what keeps the synchronous pre-rebuild callback free of I/O. +- **`resolve_adapter(key, reason)`** — the single place a missing adapter becomes a named error, used by both dispatch and the transport's default path. +- **`span_panel_api.dispatch.select_adapter_key`**, so the transport can dispatch without importing the factory. `adapters.py` answers "what is installed"; `dispatch.py` answers "what does this panel need". +- **`SpanPanelAdapterMissingError`, `SpanPanelSchemaVersionError` and `SpanPanelAdapterIncompatibleError`**, all exported from the top-level package. The three are separate because the remedy differs: missing means install something, a schema version no + adapter can even be named for means there is nothing to install yet, and incompatible means installing more cannot help. Reporting the third as the first sends someone to install a package they already have. Discovery only _logs_ a rejection, so one + unusable third-party adapter cannot take down a panel whose own adapter is fine; the error surfaces only when the rejected adapter turns out to be the one required. +- **`SpanMqttClient(adapter_factory=...)` is optional.** When omitted the parser is resolved through entry-point discovery at `_build_adapter()`. Resolution is lazy by design: constructing a client must not require an adapter to be installed, only building + a parser must. Dispatch happens wherever a parser is built, so a directly constructed client dispatches exactly as the factory path does. +- **`V2HomieSchema.data_model_version`**, carrying the `dataModelVersion` field and `None` when the panel omits it. Absence is the flat signal and stays distinct from an empty string. + +#### Surviving a firmware upgrade + +- **A panel that changes schema generation mid-life is redispatched rather than reloaded.** The schema is refetched over REST and the parser swapped in place, so an install that upgrades from flat to parent/child keeps running. The new adapter is resolved + **before** any state is touched, so a flat-only install that meets a parent/child panel logs which package is missing and keeps the parser it has instead of raising into a background task. +- **The wait for a panel to finish rebooting does not give up.** Any bound here is sized against a reboot somebody measured, and the next reboot is not that reboot — a live firmware upgrade has been observed taking four minutes from MQTT dropping to the + broker returning, still answering `502` at that point. Giving up has nothing to recommend it: the only things that start another attempt are the reconnect edge and the panel republishing its data-model version, and a panel that finishes booting after the + wait expired produces neither, so running out of attempts means stranded until somebody reloads by hand. +- **The retry interval settles at thirty seconds rather than growing.** Backing off without a ceiling would mean a panel that took a while to return was then ignored for longer than it took. The gap goes 1, 2, 4, 8, 16, 30 and stays there, so once your + panel is answering it is noticed within half a minute however long the wait has already run. Waiting costs nothing you were relying on — energy sensors hold their last reading through an outage on their own grace period, which is untouched by this — and + what is left is one request every thirty seconds to a device on your own network. +- **Nothing escapes the redispatch task.** An unexpected failure there used to surface as a bare `Task exception was never retrieved` while the parser silently stayed on the old generation. It is logged at ERROR naming the consequence and the remedy, + because a reload is the user's only move and nothing else was going to tell them. -### Changed +#### Injected HTTP client on the runtime path - **`SpanMqttClient` accepts an `httpx_client`, and so does `create_span_client`.** Four config-flow-facing entry points already took an injected client; the runtime path was the one that did not, so every schema read built a throwaway — including the retry loop that runs during a firmware upgrade, which built one per attempt at exactly the moment the panel was mid-reboot. Optional and defaulted, so nothing outside Home Assistant changes. The ownership rule is the one the existing entry points already - state: a client handed in is never closed here, and its timeouts, limits and headers are the caller's, which is why the per-call `timeout` defaults are ignored when one is given. Home Assistant's shared client carries httpx's default timeout rather than - this library's 10 s, and that is the caller exercising the policy it owns rather than a setting being lost. - -## [3.0.0b6] + state: a client handed in is never closed here, and its timeouts, limits and headers are the caller's, which is why the per-call `timeout` defaults are ignored when one is given. -### Added - -- **`SpanPanelSnapshot.lugs_at_service_entrance`, saying whether this enclosure's upstream lugs are the utility connection point.** `instant_grid_power_w` is those lugs' `meter/active-power`, and the name holds only at the service entrance: a BESS wired - ahead of the main lugs, or an enclosure fed by another enclosure, leaves the lugs metering panel-side flow while the utility side differs by whatever that device contributes or absorbs. `power_flow_grid` stays site-level and correct in both, so the two - legitimately disagree — and before this a consumer seeing them disagree could not tell a topology from a fault. Sourced from the lugs' `connection/fed-by-device-id`, which `power-flows` 0.3 names as the detection mechanism when it qualifies its own - negation table; this library already read that property and then discarded it, so no consumer could compute this for itself. Defaults `True` because flat firmware predates chaining and a flat panel's lugs really are its service entrance, so schema_0 - leaves it alone. Additive, so it costs no protocol member and no contract bump. Worth knowing: the reference capture publishes `fed-by-device-id: bess` on its upstream lugs, so the reference panel reports `False`. - -- **An adopted device carries the proxy link it declares: `AdoptedDevice.parent` and `AdoptedDevice.proxied`.** Carried rather than acted on — an adopted device is still registered under the enclosure — because a _proxied_ unmodelled device is a real shape - that would otherwise be flattened away unrecorded. The reference tree already contains one: `bess-mid` declares `parent: bess`, the `{proxier-id}-{proxied-id}` naming of `devices/proxy.md`. `proxied` is derived against the tree `root` in the adapter, - because device ids are opaque and a consumer holding one device cannot tell the enclosure's id from a sibling's. -- **The nesting is deliberately not built yet.** [python-sdk#49](https://github.com/electrification-bus/python-sdk/issues/49#issuecomment-5359203067) records that proxied ids differ by design and that consumers correlate by `info/serial-number` rather than - by device id, and `ebus-sdk` 0.21.0 shipped `DeviceSpec`/`DeviceTreeBuilder` ([python-sdk#57](https://github.com/electrification-bus/python-sdk/issues/57)) with the graph builder still to be reconciled against it. The tree model is being reshaped - upstream, so the fields capture the evidence and the topology waits. - -- **A settable property on an adopted device can be written, and the write cannot reach anything else: `AdoptedProperty.set_topic` and `SpanMqttClient.set_adopted_property`.** The topic is populated only for a settable property on a device `is_modelled` - rejects, so it is the scoping that authorises the write rather than a check a caller has to remember. The transport resolves the property against the current snapshot's `adopted_devices` and publishes to the topic that property carries; no topic is - accepted from the caller, and a device this library models produces no `AdoptedDevice` to find. -- **The alternative was a `set_property_topic` member on `SchemaAdapter`, and it was rejected for two independent reasons.** It would have put every curated control one argument away, and two of them do real work on the way out — - `dominant_power_source_payload` translates `GRID` into the `ON_GRID` the v1.0 islanding assertion accepts, and `evse_charge_limit_payload` refuses a value above the commissioned ceiling because publishing past it is the one write with a physical - consequence. It would also have been required of every adapter package, since `_derive_required_members` derives the required set from the protocol, so an installation carrying an older adapter wheel would have failed at _discovery_ rather than losing - one feature. -- **No translation and no bounds check on an adopted write, deliberately.** Both exist on curated controls because this library knows what those properties mean. It knows nothing about an adopted one beyond its declaration, and inventing a bound would be - inventing a fact about somebody else's hardware. The consumer constrains the value to the declared `format`; the panel stays the authority on whether to accept it. -- **`AdoptedControlProtocol`**, so a consumer asks `isinstance` before offering the control, exactly as it does for circuit, panel and EVSE control. - -- **A device type this adapter models nothing for is reported whole rather than ignored: `SpanPanelSnapshot.adopted_devices`.** `TreeRoles` sorts the tree into the roles the snapshot needs, and anything that matches none of them has always fallen off the - end silently — a panel publishing a device nobody modelled produced no field, no metadata row and no sign it was there. The schema is explicitly vendor-extensible, so that is an expected arrival rather than a hypothetical one. `AdoptedDevice` carries the - device's identity and its readings; `span_panel_api_schema_1.adoption` builds one per unmodelled child. -- **The unit is a device, never a property, and that is the whole design.** A new property on a device this adapter already models is a curation task with a short turnaround, and surfacing it automatically spends a consumer's entity identity permanently on - a shape a human would likely have chosen differently — the sixteen `pcs` properties that curation collapsed into one entity and thirteen attributes are the worked example. An unmodelled _type_ is the opposite case: no curation is coming, so the silence - is the only alternative. Extra instances of a modelled type are deliberately not adopted either: a second BESS is a multiplicity limit, not an unmodelled device, and adopting it would stand a machine-named record beside a curated one for the same - hardware. -- **`info` and `connection` resolve away from readings, by node rather than by property name.** `info` is a device's build identity and becomes the card fields `AdoptedDevice` carries; `connection` is topology and becomes the device link. The partition is - keyed on the node because the catalogs carry no marker for "this string is a device reference", which leaves a hard-coded name list as the only alternative — and such a list goes stale silently: `ebus-sdk`'s own `topology.py` covers `feeds-device-id` and - `fed-by-device-id` and omits `grid-forming-entity`, which lives on the `grid` capability. A node is what the vocabulary defines, so keying on it cannot go stale the same way. -- **`AdoptedProperty` carries the value; `DiscoveredMetadata` still must not.** The two answer opposite questions and are separate types so that conflating them is a type error. Discovery rows are built to be forwarded in consumer diagnostics, which leave - the machine, so they carry declarations only. An adopted property exists to become an entity on the machine that built it, so it carries the reading — along with the declared `format` and `settable` flag, which are together the value domain a consumer - needs to build a control rather than a reading. -- **Additive, and deliberately not a protocol member.** `adopted_devices` defaults to `()`, so schema_0 — which has no device tree to find an unmodelled device in — is untouched, and `ADAPTER_CONTRACT_VERSION` does not move. `SchemaAdapter` derives its - required members from itself, so a member there would be required of every adapter package and would invalidate built wheels. - -- **The capability catalogs are used as a validator, not just as a vocabulary list: `span_panel_api_schema_1.catalog`.** Sixteen catalogs have been vendored since v1.0 landed and were read only to assert that a catalog _exists_ for every node the adapter - addresses. Nothing compared a declared `unit` or `datatype` against the catalog's definition of the same property, which is the comparison that catches a mislabel — and the one mislabel this repository has met (`meter/active-power` declared `kW` while - the values are watts, a 1000x error) was found because a person noticed a sibling device declaring the same quantity differently. The new module compares one declaration against one catalog definition and classifies the result; - `tests/test_catalog_divergence.py` runs it across all four vendored producer captures and holds the outcome against an acknowledged-divergence register. -- **Agreement is silence; disagreement is surfaced, never silently resolved.** A finding is not a licence to change a wire reader to match the catalog, nor to assume the catalog is right — both sides have been wrong. It is recorded in `_REGISTER` with what - the wire says, what the catalog says, which producers show it, a reason and a date, and the baseline fails in both directions: a new divergence fails until somebody records it, and a recorded divergence that has **disappeared** fails until its line is - removed. That second direction is what keeps the register self-cleaning rather than a suppression list. -- **An abstract unit is a dimension, and comparing it as a string would report conformance as the defect.** `soc/soe`, `soc/total-energy-storage`, `soc/loadup-headroom` and `info/nameplate-capacity` are all `unit: "energy"`, which the specification - requires a publisher to substitute a real unit for — a BESS in kWh, a water heater in Wh. `UNIT_FAMILIES` enumerates membership rather than deriving it from an SI-prefix rule, so a member is silent, echoing the placeholder back is a finding, and an - energy unit nobody enumerated is a question for a human. A catalog unit token that is neither a known family nor a known concrete unit fails until it is classified, so a new abstract family upstream cannot arrive as sixty false findings. -- **An absence is terminal and is reported once.** A property no catalog defines — the EVSE's `config` node, which is not an eBus capability at all, and the `status`/`meter`/`info` extensions SPAN publishes — has no definition to disagree with, so it is - reported as absent rather than as every field mismatching against nothing. That keeps `_SPAN_EXTENSIONS` the single home for the read-set half of that question instead of duplicating its judgements here. -- **The flat schema document is surveyed too, and it is where the known mislabel lives.** It declares properties per device type with no capability node to look a catalog up by, so its properties reach the catalogued vocabulary through the snapshot field - path both adapters' metadata tables already name — derived from those tables rather than restated, so the join cannot outlive them. The join is admitted only where the two sides spell the property identically: fifteen flat properties reach a catalogued - property under a different name (`dipole` for `breaker/poles`, `software-version` for `info/firmware-version`), and comparing across a rename would invent divergences out of the pre-catalog spelling that having two adapters already handles. - -- **Per-DER connection health reaches the snapshot: `SpanEvseSnapshot.connected` and `SpanPVSnapshot.connected`.** `battery.connected` has carried the enclosure's view of the link to the BESS since v1.0 landed, from the upstream lugs' - `connection/fed-by-device-status`. The other half of the same capability — a circuit's `connection/feeds-device-status`, which is how the enclosure reports the link to a PV or a charger — reached nothing, so only one of a panel's three DER classes had a - link-health field. Both new fields are `bool | None` and mirror `battery.connected` exactly, read by `build_pv` and `build_evse` through the new `feed_connection_statuses`. -- **`None` is the specification's "unknown", and it is load-bearing.** The enum is `OK,LOST,DEGRADED` with no `UNKNOWN` member, so an unpublished property is the only way a panel can say it does not know — and `distribution-enclosure.md` states that a - mixed-load or unsurveyed circuit publishes no connection record at all, which is the normal state for most of a panel's circuits. So absence is never a fault: a DER no circuit claims, or one whose circuit publishes an id without a status, reports `None` - rather than `False`. `DEGRADED` collapses to `False`, because the question this field answers is whether the enclosure can talk to the device. -- **The charger's link is not the charger's session.** `evse.status` is the OCPP-style state the charger reports about the cable in front of it; `evse.connected` is the enclosure reporting whether it can reach the charger at all. A charger mid-session over - a lost link publishes `CHARGING` and `connected=False` at once, and the two fields stay separate for the same reason `battery.connected` and `battery.communication_state` do. -- **`_PROPERTY_FIELD_MAP` rows for both**, from `(circuit, connection, feeds-device-status)` — the one place a row's device type and its field path deliberately differ, because v1.0 states the relationship on the circuit and the field belongs to the DER. - One property carries two rows, since one circuit's record describes a PV and another's a charger. Both buy the datatype the circuit's own `$description` declares plus the three-way resolution contract. - -- **The BESS's own meter and link health reach the snapshot: `SpanBatterySnapshot.power_w` and `SpanBatterySnapshot.communication_state`.** The battery device has published `meter/active-power` and `status/communication-state` all along and neither reached - a field, so a consumer could show the enclosure's arbitrated `power_flow_battery` and nothing the BESS itself reports. Both are `None` on a BESS that publishes no such node, and on every flat panel — the flat schema's BESS device class declares neither - property, so this is new surface rather than a re-sourcing, and nothing that exists today changes. -- **`power_w` is discharge-positive, and the wire is not.** The enclosure meters the BESS the way it meters a circuit it feeds, so a _discharging_ battery publishes a negative `meter/active-power`; `build_battery` negates it, exactly as `build_circuit` - does for a load. Positive therefore means power flowing _out of_ the battery. This entry said charge-positive until the direction was settled by measurement rather than by reading: with the producer driven into self-consumption and the grid at exactly - zero — PV 4181 W plus battery 1917 W meeting a 6099 W load, so the battery can only be discharging — the snapshot reported `+1917.49`. `_charge_positive` was renamed `_discharge_positive` in the same pass. No published value changed; the negation was - always there and always right, and only the name and this note asserted a direction the code did not hold. -- The asymmetry with `panel.power_flow_battery` is real and unchanged: the enclosure's own arbitrated figure is passed through untouched by both adapters and is charge-positive, so it reads negative for the same discharging battery that makes `power_w` - positive. The two describe the same physical power in opposite frames, and a consumer rendering both negates one of them — which is what the Home Assistant integration does, landing both of its entities on discharge-positive. -- **`communication_state` stays the published enum string** (`OK`/`DEGRADED`/`LOST`/`UNKNOWN`) rather than collapsing to a bool: `DEGRADED` is neither `OK` nor `LOST`, and a bool would have to pick one. It is deliberately not merged into - `battery.connected`, which is the _enclosure's_ `connection/fed-by-device-status` view of the same link. One is the device speaking about itself and the other the panel speaking about it, and the migration guide warns against conflating them. -- **`_PROPERTY_FIELD_MAP` rows for both**, which buys them the unit and datatype the BESS's own `$description` declares plus the three-way resolution contract — a BESS that publishes the node while omitting the property reports degradation rather than - absent hardware. The row describes the property; the sign flip the mapper applies is not a unit change. - -- **`shed-forecast` reaches the snapshot: five new `SpanPanelSnapshot` fields.** `shed_time_to_priority_shed_min`, `shed_total_time_remaining_min`, `shed_full_charge_time_to_priority_shed_min`, `shed_full_charge_total_time_remaining_min` and - `shed_forecast_confidence`. The enclosure has published `energy.ebus.capability.shed-forecast` 0.1 since r202633 and nothing read it — the backup-planning numbers ("how long before my battery starts shedding circuits", "how long before it is exhausted") - were on the wire and stopped at the transport. All four times are `integer` minutes as the capability declares, parsed through `panel.integer` so a publisher that serialises a whole number with a decimal point still resolves; `confidence` stays the raw - `LOW`/`MEDIUM`/`HIGH` string, because it qualifies the four times rather than standing alone. Every field is `None` when the panel publishes no such node, and `None` is load-bearing: zero minutes is a legitimate reading — shedding starts now — so a - defaulted zero would be indistinguishable from the worst forecast the capability can report. Purely additive; a panel that publishes nothing here is unchanged. -- **`_PROPERTY_FIELD_MAP` rows for the two live estimates**, `panel.shed_time_to_priority_shed_min` and `panel.shed_total_time_remaining_min`. That buys them the unit and datatype the device's own `$description` declares, and with it the three-way - resolution contract: a panel that publishes the node while omitting one of the two reports degradation rather than absent hardware. The `full-charge-*` pair and `confidence` deliberately get no row — a consumer renders them beside the two live estimates - rather than as readings of their own, so there is no unit surface for a row to describe. -- **`shed-forecast` 0.1 vendored under `packages/schema-1/spec/catalogs/`** and pinned in `spec_lock.json`, byte-copied from the specification at the recorded `synced_commit`. The conformance suite requires a catalog for every capability node the adapter - addresses, so a node read without one would be unchecked while looking checked. - -## [3.0.0b5] - 08/2026 - -Pre-release. Publishes the captured schema document consumers were copying by hand. +#### Reference payloads shipped in the wheel -### Added - -- **`span_panel_api.reference_payloads`, shipping `homie_schema.json` as package data.** The captured `GET /api/v2/homie/schema` response moves out of `tests/fixtures/v2/` and into the wheel, reached by `homie_schema()` and `homie_schema_types()` rather - than by path. It was already being consumed outside this repository: the Home Assistant integration checks the field paths it declares against what an adapter can actually produce, which needs a real schema document, so it vendored a byte copy with a - README explaining where the copy came from. A copy has no version — it goes stale in silence, and a stale one turns the integration's conformance gate into a check against a schema no panel runs. Shipped, the payload carries the version of the release it - came with: pin `span-panel-api==3.0.0b5` and you read the bytes that release was written against, with nothing left to keep in sync. `homie_schema_types()` returns `HomieSchemaTypes` — precisely what - `span_panel_api_schema_0.field_metadata.build_field_metadata` accepts — so a caller building metadata never reaches into an untyped document to get it. This distribution owns the schema document rather than an adapter one because it is the response of - `get_homie_schema()` here, modelled by `V2HomieSchema` here, and dispatch reads its `data_model_version` to decide which adapter parses the panel at all. The parent/child device tree is the other half and ships from `span-panel-api-schema-1`, with the +- **`span_panel_api.reference_payloads`, shipping `homie_schema.json` as package data.** The captured `GET /api/v2/homie/schema` response is reached by `homie_schema()` and `homie_schema_types()` rather than by path. It was already being consumed outside + this repository — the Home Assistant integration checks the field paths it declares against what an adapter can actually produce — by vendoring a byte copy with a README explaining where the copy came from. A copy has no version: it goes stale in + silence, and a stale one turns the integration's conformance gate into a check against a schema no panel runs. Shipped, the payload carries the version of the release it came with. `homie_schema_types()` returns `HomieSchemaTypes`, precisely what + `span_panel_api_schema_0.field_metadata.build_field_metadata` accepts, so a caller building metadata never reaches into an untyped document to get it. The parent/child device tree is the other half and ships from `span-panel-api-schema-1`, with the parser that can interpret it. -- **This suite reads the payload through the same accessor.** `test_schema_provenance.py` and `test_detection_auth.py` no longer open a path, so the schema anchor is checked against the bytes a consumer installs rather than against a file that exists only - in a checkout. - -## [3.0.0b3] - 08/2026 -Pre-release. Normalises DER identity onto v1.0's vocabulary, and stops deriving the grid answers that v1.0 states outright. - -### Changed - -- **BREAKING — DER identity speaks v1.0's vocabulary on every device class.** `model` is the human designation and `part_number` the SKU, on `battery`, `evse` and `pv` alike. `product_name` is retired on all three. Flat is the inconsistent side, not v1.0: - it puts the SKU in `bess/model` and in `evse/part-number`, the same concept under two names, and gives PV neither. `schema_1` used to cross over (`info/part-number` → `battery.model`) to hold each entity's displayed meaning still, which worked and - permanently encoded flat's irregularity in the snapshot. `schema_0` now translates flat into the normalised shape instead of mirroring it. Measured: every EVSE identity field reads identically on both adapters, so for that device class identity stops - being a migration delta at all. **`battery.model` changes value for existing flat users at this upgrade** — it gains the designation where it carried the SKU. That is the deliberate trade: a change we schedule in a library release beats the same change - arriving unplanned during a firmware upgrade a user did not choose the timing of. -- **Consumers reading `product_name` must move to `model` in the same release.** The Home Assistant integration builds its device-registry model from it; left unchanged, device cards go blank. - -### Added +#### New snapshot surface -- **`SpanMidSnapshot`, and `SpanPanelSnapshot.mid`.** v1.0 publishes a Microgrid Interconnect Device and the enclosure model puts the `grid` capability on it rather than on the enclosure, so islanding state, grid state and the grid-forming entity live - there. Previously one of its five properties was read and the device discarded. Purely additive: no flat panel publishes a MID, so nothing existing changes. Presence is `snapshot.mid is not None` rather than a sentinel field, and identity is - `info/serial-number` rather than the Homie device id, which the proxy model warns is not stable across a proxy-to-native transition. +Everything below is additive. Each field is `None` or empty on a panel that publishes no such thing, and no flat panel publishes any of it unless stated. -### Fixed - -- **Adapter discovery no longer blocks the caller's event loop, and no longer imports adapters the panel will never use.** Two defects with one cause: discovery resolved the whole entry-point group up front, on the calling thread. A flat panel therefore - imported `schema_1` — and with it the eBus SDK and jsonschema — on every connection, for a parser it would not call. Home Assistant reported the whole sequence (`listdir`, `read_text`, `open`, `scandir`) as blocking calls inside the event loop and asked - for a bug report, with setup stalled 2.0s on a cold import cache. Enumeration and resolution are now separate: `installed_adapter_keys()` reads distribution metadata only, and an adapter is imported the first time a panel asks for that key. The async - paths run both in a thread. Resolution stays cached per key, which is what keeps the synchronous pre-rebuild callback free of I/O. **`discover_adapters()` is replaced by `installed_adapter_keys()`**, which returns registered names rather than a registry - of loaded classes — verifying every name would mean importing every package, which is the cost being removed. `SpanMqttClient.available_adapters` becomes `installed_adapters` for the same reason. -- **A firmware upgrade to a schema generation this install cannot parse is reported instead of raised into a background task.** The redispatch path resolves the new adapter before touching any state, so a flat-only install that meets a v1.0 panel logs - which package is missing and keeps the parser it has. Previously `SpanPanelAdapterMissingError` escaped a fire-and-forget task as a bare traceback. -- **`dsm_state` and `current_run_config` are read from the MID instead of reading `UNKNOWN`.** Both are existing entities that had degraded on v1.0 — not because a source vanished, but because `schema_0` _derives_ them and the derivation was never ported. - v1.0 states the answer, so the multi-signal heuristic is gone: sensed from a ready MID, falling back to the user's `shed/asserted-islanding-state` when it is not ready, then to a `power-flows/grid` heuristic when there is no MID at all, and unknown - otherwise. A missing MID never reports on-grid — it means SPAN is not the islanding authority, not that the site is on grid, and a generator-fed island is the counterexample. `PANEL_BACKUP` versus `PANEL_OFF_GRID` becomes authoritative rather than - guessed, because v1.0 names the forming device and its class is recoverable from the tree. +- **`SpanMidSnapshot` and `SpanPanelSnapshot.mid`.** The parent/child model puts the `grid` capability on a Microgrid Interconnect Device rather than on the enclosure, so islanding state, grid state and the grid-forming entity live there. Presence is + `snapshot.mid is not None` rather than a sentinel field, and identity is `info/serial-number` rather than the Homie device id, which the proxy model warns is not stable across a proxy-to-native transition. +- **`dsm_state` and `current_run_config` are read from the MID.** Both are existing entities that would otherwise degrade to `UNKNOWN` on a parent/child panel: `schema_0` _derives_ them from a multi-signal heuristic, and the parent/child model states the + answer outright. Sensed from a ready MID, falling back to the user's `shed/asserted-islanding-state` when it is not ready, then to a `power-flows/grid` heuristic when there is no MID at all, and unknown otherwise. A missing MID never reports on-grid — it + means SPAN is not the islanding authority, not that the site is on grid, and a generator-fed island is the counterexample. `PANEL_BACKUP` versus `PANEL_OFF_GRID` becomes authoritative rather than guessed. - **`grid_islandable` is mapped to `grid-forming/capable`** over the BESS's inverter children, as the disjunction — a panel does not island, its DER does, and flat expressed a property of the DER as a property of the enclosure. It returns `None` rather than `False` when nothing publishes it, so absence stays a gap instead of becoming a claim. No producer publishes it today, which is recorded rather than worked around. -- **EVSE identity survives the migration.** The snapshot key and `node_id` — which a consumer builds a `unique_id` and a device-registry identifier from — were the v1.0 device id on `schema_1` and firmware's node name on `schema_0`, so every charger would - have orphaned and reappeared as a duplicate. Both are the Drive's serial now, which is what real flat firmware keys by. - -## [3.0.0b2] - 08/2026 - -Pre-release. Releases the reshaped `SchemaAdapter` protocol that `3.0.0b1` predates, and makes the mismatch between the two detectable rather than fatal at construction. - -### Added - -- **Adapter contract versioning.** `SchemaAdapter` now requires an `ADAPTER_CONTRACT` integer, and discovery rejects any adapter that does not declare this package's `ADAPTER_CONTRACT_VERSION`. Member presence was never the whole contract: a Protocol - cannot express signatures at runtime, so an adapter carrying every required name and the previous `__init__` arity passed discovery and failed much later inside the transport, as a bare `TypeError` about an argument count — the least actionable moment to - learn that two installed packages were built against different versions of each other. Adapters must declare the value as a **literal**; one read from the installed bootstrap would agree with every bootstrap, which is the disagreement being looked for. -- **`SpanPanelAdapterIncompatibleError`**, raised when the adapter a panel needs is installed but unusable. Distinct from `SpanPanelAdapterMissingError` because the remedy inverts: missing means install something, incompatible means installing more cannot - help. Reporting the second as the first sends someone to install a package they already have. Discovery still only _logs_ a rejection, so one unusable third-party adapter cannot take down a panel whose own adapter is fine; the error surfaces only when - the rejected adapter turns out to be the one required. - -### Fixed - -- **`data-model-version` dispatch is live.** The factory hardcoded `None`, so the guard that refuses a parent/child panel was written, tested and never invoked — every panel resolved to the flat parser regardless of what it reported. The Homie schema is - now fetched over REST **before** the broker is opened and the version drives adapter selection, which SPAN confirmed is a reliable flat-versus-parent/child signal on that endpoint. A `1.0` panel now raises `SpanPanelAdapterMissingError` naming the - adapter to install, instead of dying inside the flat parser on a missing `energy.ebus.device.circuit/space` property. -- **A directly constructed `SpanMqttClient` dispatches too.** Building a client without `create_span_client` previously always resolved the flat adapter, so it carried the same defect the factory path had. Dispatch now happens wherever a parser is built, - and fills in `data_model_version` / `schema_dispatch_reason` rather than leaving them reading `"not dispatched"`. - -### Changed - -- **BREAKING: `SchemaAdapter.__init__` takes the schema, not a panel size.** `adapter_cls(serial_number, schema)` replaces `adapter_cls(serial_number, panel_size)`. `panel_size` is read out of a block only the flat schema has, so the bootstrap had to - understand a wire format it is meant to know nothing about, and an adapter whose schema is shaped differently had no way to say so. Each adapter now reads what its own format defines. -- **BREAKING: `SchemaAdapter.build_field_metadata()` takes no arguments.** It previously received `schema.types` — again a flat-shaped parameter on a format-agnostic protocol. The adapter holds the schema it was constructed with. -- **`V2HomieSchema.data_model_version`** carries the `dataModelVersion` field, `None` when the panel omits it. Absence is the flat signal and stays distinct from an empty string. -- **Tier 1 dispatch moved to `span_panel_api.dispatch.select_adapter_key`** from the private `factory._select_adapter_key`, so the transport can dispatch without importing the factory. `adapters.py` continues to answer "what is installed"; the new module - answers "what does this panel need". - -## [3.0.0b1] - 08/2026 - -Pre-release. `span-panel-api` becomes a transport and a dispatcher that contains **no parser**. Wire formats ship as separate distributions and register themselves via entry points, so support for a new panel schema arrives by installing a package rather -than by upgrading the transport. This is prototype work being proven end to end before any decision to land it on `main`. - -### Removed - -- **BREAKING: `span-panel-api` no longer contains a parser.** Installing it alone gives a client that connects and then raises `SpanPanelAdapterMissingError`. Flat-schema panels (firmware `r202603`–`r202627`) need **`span-panel-api-schema-0`** installed - alongside it: - - ```console - pip install span-panel-api span-panel-api-schema-0 - ``` - -- **BREAKING: `HomieLifecycle`, `HomiePropertyAccumulator` and `HomieDeviceConsumer` are no longer exported** from `span_panel_api` or `span_panel_api.mqtt`. All three are flat-schema-specific rather than Homie-convention-level: the accumulator filters - every topic against a single device's prefix and stores `node → prop`, which drops nearly every message under the parent/child model; `HomieLifecycle`'s members are not Homie 5 `$state` values but a consumer-side progression encoding "one description - received ⇒ ready", which is the flat readiness model. They now live in `span_panel_api_schema_0`. -- **Removed dead constants** `DEVICE_TOPIC_FMT`, `STATE_TOPIC_FMT`, `DESCRIPTION_TOPIC_FMT`, `PROPERTY_TOPIC_FMT` (unreferenced before the Phase 0 relocation) and `TYPE_PCS` (a real schema type this library does not consume). - -### Added - -- **`span_panel_api.adapters.resolve_adapter(key, reason)`** — the single place a missing adapter becomes a named error, used by both Tier 1 dispatch and the transport's default path. -- **`SpanPanelSchemaVersionError`**, raised when a panel reports a `data-model-version` whose schema major cannot be determined. Distinct from `SpanPanelAdapterMissingError` because the remedy differs: a missing adapter is a known schema with no installed - parser, while this is a schema no adapter can even be named for. -- **`SpanPanelAdapterMissingError` and `SpanPanelSchemaVersionError` are now exported** from the top-level package — both are errors a user sees when their panel outruns their install, so catching them should not require reaching into a private module. -- **`SchemaAdapter.__init__` is declared on the protocol.** Construction was always part of the contract (the transport resolves an adapter class from the registry and calls it), but was previously typed only as a `Callable`, leaving the signature - unchecked against implementations. -- **Entry-point validation.** `discover_adapters()` now verifies each loaded object is a class implementing the protocol before registering it, and skips it with a logged reason otherwise. One broken third-party adapter cannot take down a panel whose own - adapter is fine. -- **`scripts/verify_adapterless_install.py`** and a CI step that runs it against a venv holding only the bootstrap wheel. - -### Changed - -- **`SpanMqttClient(adapter_factory=...)` is now optional.** When omitted, the parser is resolved through entry-point discovery at `_build_adapter()` rather than imported. Resolution is lazy by design: constructing a client must not require an adapter to - be installed, only building a parser must. -- **Dispatch refuses an unreadable `data-model-version` instead of assuming flat.** Absence still means the flat schema — that is a real signal, since the property was introduced by the firmware that introduced parent/child. A value whose major _can_ be - read but whose form is non-canonical (`1`, `1.0-beta`) dispatches on that major and logs the deviation. A value with no extractable major now raises. Previously all three fell through to the flat parser, which does not fail — it produces plausible but - wrong power and energy figures. -- **Dispatch diagnostics travel through the `SpanMqttClient` constructor**, removing the window where a connected client reported a selected adapter alongside `schema_dispatch_reason='not dispatched'`. -- **Releases are now per-distribution and the tag no longer sets the version.** A tag selects which distribution to publish — `vX.Y.Z` for `span-panel-api`, `schema-N-vX.Y.Z` for an adapter — and the release fails unless the tagged version matches the one - committed in that distribution's `pyproject.toml`. The previous workflow rewrote the root version from the tag and built only the root package, which under a two-distribution layout would have published the bootstrap with no adapter alongside it. Version - numbers are now load-bearing between the two (the adapter declares a floor on the bootstrap), so they belong in the repository rather than being stamped at release time. - -### Fixed - -- **Adapter distributions ship a `py.typed` marker.** Without it a consumer's type checker refuses to read the adapter's annotations and resolves every symbol it exports as `Any`, silently erasing the strict typing at the wheel boundary. CI now fails any - wheel built without one. -- **Protocol conformance checking no longer depends on member kind.** The required-member set is derived from every public member `SchemaAdapter` declares, not only the callable ones — a `property` or `classmethod` object is not callable, so the previous - derivation would have quietly stopped requiring such a member the day the protocol declared one. +- **`SpanPanelSnapshot.lugs_at_service_entrance`, saying whether this enclosure's upstream lugs are the utility connection point.** `instant_grid_power_w` is those lugs' `meter/active-power`, and the name holds only at the service entrance: a BESS wired + ahead of the main lugs, or an enclosure fed by another enclosure, leaves the lugs metering panel-side flow while the utility side differs by whatever that device contributes or absorbs. `power_flow_grid` stays site-level and correct in both, so the two + legitimately disagree — and before this a consumer seeing them disagree could not tell a topology from a fault. Sourced from the lugs' `connection/fed-by-device-id`, which `power-flows` 0.3 names as the detection mechanism when it qualifies its own + negation table. Defaults `True`, because flat firmware predates chaining and a flat panel's lugs really are its service entrance. +- **`SpanBatterySnapshot.power_w` and `SpanBatterySnapshot.communication_state`.** The battery device has always published `meter/active-power` and `status/communication-state` and neither reached a field, so a consumer could show the enclosure's + arbitrated `power_flow_battery` and nothing the BESS itself reports. `power_w` is **discharge-positive**: the enclosure meters the BESS the way it meters a circuit it feeds, so positive means power flowing _out of_ the battery, matching the eBus rule for + a device's own meter. The asymmetry with `panel.power_flow_battery` is deliberate — the enclosure's arbitrated figure is passed through untouched by both adapters and is charge-positive, so it reads negative for the same discharging battery that makes + `power_w` positive. The two describe the same physical power in opposite frames, and a consumer rendering both negates one of them. `communication_state` stays the published enum string (`OK`/`DEGRADED`/`LOST`/`UNKNOWN`) rather than collapsing to a bool, + because `DEGRADED` is neither `OK` nor `LOST`; it is deliberately not merged into `battery.connected`, which is the _enclosure's_ view of the same link. +- **`SpanEvseSnapshot.connected` and `SpanPVSnapshot.connected`.** `battery.connected` has carried the enclosure's view of the link to the BESS from the upstream lugs' `connection/fed-by-device-status`; the other half of the same capability — a circuit's + `connection/feeds-device-status` — reached nothing, so only one of a panel's three DER classes had a link-health field. `None` is the specification's "unknown" and is load-bearing: the enum is `OK,LOST,DEGRADED` with no `UNKNOWN` member, and a mixed-load + or unsurveyed circuit publishes no connection record at all, which is the normal state for most of a panel's circuits. So absence is never a fault. `DEGRADED` collapses to `False`, because the question this field answers is whether the enclosure can talk + to the device. The charger's link is not the charger's session: `evse.status` is the OCPP-style state the charger reports about the cable in front of it, and a charger mid-session over a lost link publishes `CHARGING` and `connected=False` at once. +- **Five `shed-forecast` fields**: `shed_time_to_priority_shed_min`, `shed_total_time_remaining_min`, `shed_full_charge_time_to_priority_shed_min`, `shed_full_charge_total_time_remaining_min` and `shed_forecast_confidence`. The backup-planning numbers — + how long before my battery starts shedding circuits, how long before it is exhausted — were on the wire and stopped at the transport. All four times are `integer` minutes as the capability declares, parsed so that a publisher serialising a whole number + with a decimal point still resolves; `confidence` stays the raw `LOW`/`MEDIUM`/`HIGH` string, because it qualifies the four times rather than standing alone. `None` is load-bearing here too: zero minutes is a legitimate reading — shedding starts now — so + a defaulted zero would be indistinguishable from the worst forecast the capability can report. +- **`SpanPanelSnapshot.adopted_devices`, reporting a device type this library models nothing for rather than dropping it.** The schema is explicitly vendor-extensible, so an unmodelled device is an expected arrival rather than a hypothetical one; before + this it produced no field, no metadata row and no sign it was there. `AdoptedDevice` carries the device's identity and its readings. **The unit is a device, never a property**: a new property on a device already modelled is a curation task with a short + turnaround, and surfacing it automatically would spend a consumer's entity identity permanently on a shape a human would likely have chosen differently. An unmodelled _type_ is the opposite case — no curation is coming, so silence is the only + alternative. Extra instances of a modelled type are deliberately not adopted either: a second BESS is a multiplicity limit, not an unmodelled device. +- **`AdoptedDevice.parent` and `AdoptedDevice.proxied`**, carrying the proxy link a device declares. Carried rather than acted on — an adopted device is still registered under the enclosure — because a _proxied_ unmodelled device is a real shape that would + otherwise be flattened away unrecorded. The nesting is deliberately not built: proxied ids differ by design and consumers correlate by `info/serial-number` rather than by device id, and the tree model is being reshaped upstream, so the fields capture the + evidence and the topology waits. +- **`AdoptedProperty.set_topic`, `SpanMqttClient.set_adopted_property` and `AdoptedControlProtocol`**, so a settable property on an adopted device can be written and the write cannot reach anything else. The topic is populated only for a settable property + on a device `is_modelled` rejects, so it is the scoping that authorises the write rather than a check a caller has to remember: the transport resolves the property against the current snapshot's `adopted_devices` and publishes to the topic that property + carries, no topic is accepted from the caller, and a device this library models produces no `AdoptedDevice` to find. There is deliberately no translation and no bounds check on an adopted write — both exist on curated controls because this library knows + what those properties mean, and inventing a bound for somebody else's hardware would be inventing a fact. `AdoptedControlProtocol` lets a consumer ask `isinstance` before offering the control, exactly as it does for circuit, panel and EVSE control. +- **`SpanPanelSnapshot.extension_properties`, `ExtensionProperty` and `ExtensionSubject`**, so a vendor property on a device this library _already_ models reaches a consumer instead of stopping at diagnostics. Adoption covers the unmodelled-device half; + this covers the other one, where a new property on the BESS, a charger, a circuit or the panel would otherwise be a declaration with no value, visible only to a maintainer reading a diagnostics attachment. The subject names which modelled snapshot + subject a property hangs off — `battery`, `mid`, `pv`, `panel`, `lugs` with `upstream`/`downstream`, and `evse`/`circuit` with the instance key the snapshot's own maps use — so a consumer resolves the device with a lookup it already performs. What is + _not_ exposed is the field-level mapping: the subject is one value per device and cannot drift, while the wire-property-to-snapshot-field map is the adapter's internal business and exporting it would freeze it as API. +- **An extension property's value never reaches diagnostics, structurally.** `ExtensionProperty` is deliberately not a `FieldMetadata`, so it cannot enter the map `partition()` walks and has no path into a payload that leaves the machine. The discovery + rows keep flowing unchanged: the same property appears in both surfaces on purpose, joined by its `{node}/{property}` path — a declaration for the maintainer, a reading for the user. It is read-only by construction: it carries `settable` for curation + triage and no set topic, and there is no member a write path could be built from. ## [2.6.4] - 05/2026 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index afbb74f..5fe1a65 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -2,9 +2,19 @@ ## Prerequisites -- Python 3.10+ (CI tests 3.13 and 3.14) +- Python 3.14 (every manifest declares `>=3.14,<4.0`, and CI runs the suite on 3.14) - [uv](https://docs.astral.sh/uv/) for dependency management +**The declared floor and the tested version are the same on purpose.** A `requires-python` no job runs is a claim rather than a guarantee, and the two drift apart easily, because every developer and every other workflow here is on the newest interpreter. +Keeping them identical means a green run proves the whole declared range instead of one end of it. If the floor is ever widened, the CI matrix has to widen with it in the same change. + +The floor tracks the consumer. Home Assistant requires Python `>=3.12` from 2025.1, `>=3.13.2` from 2025.10 and `>=3.14.2` from 2026.3 — and the SPAN integration that consumes this library requires HA 2026.8 or newer, which puts every install that reaches +this code on 3.14. Declaring anything lower would describe a configuration nobody runs and nothing verifies. + +Two older versions are worth naming as specifically ruled out, so that a future "why not support 3.10?" gets answered without re-deriving it. `tests/test_packaging.py` imports `tomllib`, stdlib only from 3.11. More seriously, Python 3.10 replaces a +`Protocol`'s `__init__` with `(*args, **kwargs)`, so `SchemaAdapter`'s declared constructor signature is not introspectable there and `test_schema_adapter_construction_signature_matches_its_implementation` has nothing to read — the check that stops two +independently-versioned wheels disagreeing about how an adapter is constructed would be inert. For a library built around exactly that seam, that is the wrong place to have a hole. + ## Setup ```bash @@ -271,5 +281,14 @@ See [RELEASE.md](RELEASE.md) — each distribution versions and publishes indepe 1. Fork and clone the repository 2. Install dev dependencies: `uv sync` 3. Make changes and add tests -4. Ensure all checks pass: `uv run pytest && uv run mypy src/ && uv run ruff check src/` +4. Ensure all checks pass across every distribution, not just the bootstrap: + + ```bash + uv run pytest + uv run mypy src packages + uv run ruff check . + ``` + + `uv run pre-commit run --all-files` is what CI actually runs, and it covers these plus the markdown, security and dead-code hooks. + 5. Submit a pull request diff --git a/README.md b/README.md index b6e8db2..c1c628b 100644 --- a/README.md +++ b/README.md @@ -23,16 +23,39 @@ A Python client library for the SPAN Panel v2 API, using MQTT/Homie for real-tim ## Installation -Two packages: the transport, and a parser for your panel's schema. `span-panel-api` contains no parser — installing it alone gives a client that connects and then raises `SpanPanelAdapterMissingError`. +Two packages: the transport, and a parser for your panel's schema. `span-panel-api` contains **no parser** — installing it alone gives a client that connects and then raises `SpanPanelAdapterMissingError`. ```bash -pip install span-panel-api span-panel-api-schema-0 +# flat schema, firmware r202603-r202627 +pip install "span-panel-api[schema-0]" + +# parent/child schema, firmware r202633+ (data-model-version 1.x) +pip install "span-panel-api[schema-1]" + +# support either panel from one install +pip install "span-panel-api[schema-0,schema-1]" ``` -`span-panel-api-schema-0` parses the flat schema used by firmware `r202603` through `r202627`, which is every panel in the field today. Panels reporting a `data-model-version` need the adapter for that schema major instead; the error names the one it could -not find and lists what is installed. +The extras are the recommended spelling because they give `pip install -U` a correct upgrade path; naming `span-panel-api-schema-0` / `span-panel-api-schema-1` directly works too. + +### The parser is hot-loaded, not imported + +`span-panel-api` never imports a parser. Each wire format is its own distribution, registering itself under the `span_panel_api.schema_adapters` entry-point group, and the transport reaches it by key at runtime: -Parsers are discovered through the `span_panel_api.schema_adapters` entry-point group, so support for a new panel schema arrives by installing a package rather than by upgrading the transport. The two version independently — see [RELEASE.md](RELEASE.md). +1. **Ask the panel first.** Before the broker is opened, the client fetches `GET /api/v2/homie/schema` over REST and reads `dataModelVersion`. Absence means the flat schema — a real signal, since the property arrived with the firmware that introduced + parent/child. A value whose major can be read but whose form is non-canonical (`1`, `1.0-beta`) dispatches on that major and logs the deviation; one with no extractable major raises `SpanPanelSchemaVersionError` rather than guessing. +2. **Enumerate without importing.** `installed_adapter_keys()` reads distribution metadata only. Nothing is imported to find out what is installed, so a flat panel never pays for `span-panel-api-schema-1` — nor for the eBus SDK underneath it. +3. **Resolve on demand, once.** The adapter for the selected key is imported the first time a panel asks for it, then cached. The async paths run enumeration and resolution in a thread, so neither blocks the event loop. +4. **Verify the contract before trusting it.** Every adapter declares `ADAPTER_CONTRACT` as a literal, and discovery rejects any that does not match this package's `ADAPTER_CONTRACT_VERSION`. Member presence is not the whole contract — a Protocol cannot + express signatures at runtime — so this is what stops two packages built against different versions of each other failing much later as a bare `TypeError` inside the transport. A rejection is logged rather than raised, so one unusable third-party + adapter cannot take down a panel whose own adapter is fine. +5. **Re-dispatch when the panel changes underneath you.** A panel that upgrades firmware from flat to parent/child mid-life drops MQTT, reboots and comes back on a new schema. The client refetches, resolves the new adapter **before** touching any state, + and swaps the parser in place — no reload. An install with no adapter for the new generation logs which package to install and keeps the parser it has. + +Three errors keep the failure modes apart, because the remedy differs: `SpanPanelAdapterMissingError` (install something), `SpanPanelSchemaVersionError` (a schema no adapter can even be named for), and `SpanPanelAdapterIncompatibleError` (installing more +cannot help). All are exported from the top-level package. + +The consequence worth planning around: **supporting a new panel schema is an install, not an upgrade.** The distributions version independently — see [RELEASE.md](RELEASE.md). ### Dependencies @@ -44,10 +67,15 @@ Parsers are discovered through the `span_panel_api.schema_adapters` entry-point ### Transport -The `SpanMqttClient` connects to the panel's MQTT broker (MQTTS or WebSocket) and subscribes to the Homie device tree. A two-layer architecture separates generic Homie v5 protocol handling from SPAN-specific interpretation: +The `SpanMqttClient` connects to the panel's MQTT broker (MQTTS or WebSocket) and subscribes to the Homie device tree. It owns the connection, the subscription and the dispatch decision — and nothing else. Everything that knows what a topic _means_ lives +in the adapter for that panel's schema: + +- **The transport** (this package) makes one wildcard subscription, routes messages, tracks connection state, publishes commands, and hands raw messages to whichever parser was resolved for this panel. +- **The parser** (`span-panel-api-schema-0` or `span-panel-api-schema-1`) accumulates properties, decides when the panel is ready to read, and builds typed `SpanPanelSnapshot` dataclasses from what it has. -- **`HomiePropertyAccumulator`** — handles message routing, property and `$target` storage, dirty-node tracking, and an explicit lifecycle state machine (`HomieLifecycle`). Protocol-only; no SPAN domain knowledge. -- **`HomieDeviceConsumer`** — reads from the accumulator via a query API and builds typed `SpanPanelSnapshot` dataclasses. Handles power sign normalization, DSM derivation, unmapped tab synthesis, and dirty-node-aware snapshot caching. +That boundary is why `HomiePropertyAccumulator`, `HomieLifecycle` and `HomieDeviceConsumer` are **not** exported from this package: all three are flat-schema-specific rather than Homie-convention-level. The accumulator filters every topic against a single +device's prefix and stores `node → prop`, which drops nearly every message under the parent/child model, and `HomieLifecycle`'s members are not Homie 5 `$state` values but a consumer-side progression encoding "one description received ⇒ ready". They live +in `span_panel_api_schema_0`, where that model is correct. The parent/child parser reaches the same result differently, replaying the retained tree through the eBus SDK and waiting for every declared device to describe itself at any depth. Changes are pushed to consumers via callbacks. Dirty-node tracking allows the snapshot builder to skip unchanged nodes, reducing per-scan CPU cost on constrained hardware. @@ -68,7 +96,7 @@ This means the library can be dropped into any asyncio application — including Circuit names arrive as MQTT retained messages that may land after the Homie device transitions to `$state=ready`. The client handles this with a bounded wait during `connect()`: -1. After the device reaches ready state, the client polls `HomieDeviceConsumer.circuit_nodes_missing_names()` every 250ms. +1. After the device reaches ready state, the client polls the resolved adapter's `circuit_nodes_missing_names()` every 250ms — a `SchemaAdapter` member, so both parsers answer it in their own terms. 2. As retained name properties arrive, the consumer stores them. Once all circuit-type nodes have a name, the wait returns immediately. 3. If names have not all arrived within 10 seconds, the timeout expires (non-fatal) and the client proceeds — circuits without names will use fallback identifiers. @@ -76,28 +104,44 @@ This ensures that the first `get_snapshot()` after connect returns human-readabl ### Protocols -The library defines three structural subtyping protocols (PEP 544) that both the MQTT transport and the simulation engine implement: +The library defines structural subtyping protocols (PEP 544). All are `runtime_checkable`, so a consumer asks `isinstance` before offering a control rather than assuming the panel in front of it supports one: | Protocol | Purpose | | -------------------------- | ------------------------------------------------------------------------------------------ | | `SpanPanelClientProtocol` | Core lifecycle: `connect`, `close`, `ping`, `get_snapshot`, `register_connection_callback` | | `CircuitControlProtocol` | Relay and shed-priority control: `set_circuit_relay`, `set_circuit_priority` | | `PanelControlProtocol` | Panel-level control: `set_dominant_power_source` | +| `EvseControlProtocol` | Per-charger control: `set_evse_charge_limit(node_id, amps)` | +| `AdoptedControlProtocol` | Write to a settable property of a device this library models nothing for | | `StreamingCapableProtocol` | Push-based updates: `register_snapshot_callback`, `start_streaming`, `stop_streaming` | -Integration code programs against these protocols, not transport-specific classes. +The first five differ in subject, not just in name. `EvseControlProtocol` is separate from `PanelControlProtocol` because several chargers may be commissioned at once and every call names which one. `AdoptedControlProtocol` differs in kind: the curated +setters name a control this library understands and translate or bound the value on the way out, while this one names a property by its wire address and passes the value through, because the declaration is all anybody here knows about it. That write is +authorised by the snapshot rather than by its arguments — the transport resolves the property against the current `adopted_devices` and refuses anything it does not find carrying a set topic, so a device this library _does_ model cannot be addressed +through it. + +A seventh protocol, `SchemaAdapter`, is the bootstrap-to-parser contract rather than a consumer-facing one; it is what an adapter distribution implements and what discovery checks. Integration code programs against the protocols above, not against +transport-specific classes. ### Snapshots All panel state is represented as immutable, frozen dataclasses: -| Dataclass | Content | -| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `SpanPanelSnapshot` | Complete panel state: power, energy, grid/DSM state, hardware status, per-leg voltages, power flows, lugs current, circuits, battery, PV, EVSE | -| `SpanCircuitSnapshot` | Per-circuit: power, energy, relay state, priority, tabs, device type, breaker rating, current, `$target` pending state | -| `SpanBatterySnapshot` | BESS: SoC percentage, SoE kWh, vendor/product metadata, nameplate capacity | -| `SpanPVSnapshot` | PV inverter: vendor/product metadata, nameplate capacity | -| `SpanEvseSnapshot` | EVSE (EV charger): status, lock state, advertised current, vendor/product/serial/version metadata | +| Dataclass | Content | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `SpanPanelSnapshot` | Complete panel state: power, energy, grid/DSM state, hardware status, per-leg voltages, power flows, lugs current, shed forecast, circuits, battery, PV, EVSE, MID | +| `SpanCircuitSnapshot` | Per-circuit: power, energy, relay state, priority, tabs, device type, breaker rating, current, `$target` pending state | +| `SpanBatterySnapshot` | BESS: SoC percentage, SoE kWh, own meter reading, communication state, link health, `model` / `part_number`, nameplate capacity | +| `SpanPVSnapshot` | PV inverter: link health, `model` / `part_number`, nameplate capacity | +| `SpanEvseSnapshot` | EVSE (EV charger): status, lock state, advertised current, link health, `model` / `part_number` / serial / version metadata | +| `SpanMidSnapshot` | Microgrid Interconnect Device: islanding state, grid state, grid-forming entity | +| `AdoptedDevice` | A device type this library models nothing for, carried whole: identity, readings, proxy link | +| `ExtensionProperty` | A vendor property on a device this library _does_ model, with its value and the subject it hangs off | + +Identity is normalised across every DER class: **`model` is the human designation and `part_number` is the SKU**, on `battery`, `evse` and `pv` alike. `product_name` was retired in 3.0.0 — see the changelog, because `battery.model` changes value for +existing flat users at that upgrade. + +`mid`, `adopted_devices`, `extension_properties` and the per-DER link-health fields exist only under the parent/child schema. They are `None` or empty on a flat panel rather than absent, so a consumer reads the same snapshot type either way. ## Usage @@ -346,10 +390,21 @@ All exceptions inherit from `SpanPanelError`: | `SpanPanelTimeoutError` | Request or connection timed out | | `SpanPanelValidationError` | Data validation failure | | `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints | -| `SpanPanelServerError` | Panel returned HTTP 500 | +| `SpanPanelServerError` | Panel answered 5xx, or answered `200` with a body that cannot be used — "not ready yet" | + +Three more are specific to the hot-loading model, and they are separate because the remedy differs: + +| Exception | Cause | Remedy | +| ----------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------- | +| `SpanPanelAdapterMissingError` | Known schema, no installed parser for it | Install the named package | +| `SpanPanelSchemaVersionError` | The panel reports a `data-model-version` no adapter can even be named for | Nothing to install yet — report the value | +| `SpanPanelAdapterIncompatibleError` | The required adapter is installed but was built against another contract | Installing more cannot help — align versions | + +Reporting the third as the first would send someone to install a package they already have. `SpanPanelStaleDataError` is distinct from `SpanPanelConnectionError`: the former means the client is running but data cannot be trusted right now (transient disconnect, or panel-declared not-ready); the latter means the initial connect failed and the -client cannot be used at all. +client cannot be used at all. `SpanPanelServerError` covers the whole 5xx class deliberately: a booting panel brings its network stack and reverse proxy up before the application behind them, so it _answers_ rather than refuses, and that has to be +distinguishable from a 4xx that will not fix itself on its own. ```python from span_panel_api import ( @@ -410,27 +465,42 @@ Each payload carries the version of the release it shipped in. Pin a version and ## Project Structure +One repository, three distributions. The bootstrap is at the root; each parser is a workspace member under `packages/`, published separately and versioned on its own axis. + ```text -src/span_panel_api/ -├── __init__.py # Public API exports -├── auth.py # v2 HTTP provisioning (register, cert, schema, passphrase) -├── const.py # Panel state constants (DSM, relay) -├── detection.py # detect_api_version() → DetectionResult -├── exceptions.py # Exception hierarchy -├── factory.py # create_span_client() → SpanMqttClient -├── models.py # Snapshot dataclasses (panel, circuit, battery, PV) -├── phase_validation.py # Electrical phase utilities -├── protocol.py # PEP 544 protocols + PanelCapability flags -├── reference_payloads/ # Captured wire payloads shipped as package data +src/span_panel_api/ # distribution: span-panel-api (no parser) +├── __init__.py # Public API exports +├── _http.py # Shared httpx plumbing / client ownership rules +├── adapters.py # installed_adapter_keys(), resolve_adapter() — metadata, then lazy import +├── auth.py # v2 HTTP provisioning (register, cert, schema, passphrase) +├── const.py # Panel state constants (DSM, relay) +├── detection.py # detect_api_version() → DetectionResult +├── dispatch.py # select_adapter_key() — what does this panel need? +├── exceptions.py # Exception hierarchy +├── factory.py # create_span_client() → SpanMqttClient +├── models.py # Snapshot dataclasses (panel, circuit, battery, PV, EVSE, MID, adopted) +├── phase_validation.py # Electrical phase utilities +├── protocol.py # PEP 544 protocols, SchemaAdapter, PanelCapability flags +├── schema_drift.py # Reporting a panel that outruns what we can read +├── reference_payloads/ # Captured GET /api/v2/homie/schema, shipped as package data └── mqtt/ ├── __init__.py - ├── accumulator.py # HomiePropertyAccumulator (Homie v5 protocol layer) - ├── async_client.py # NullLock + AsyncMQTTClient (HA core pattern) - ├── client.py # SpanMqttClient (all three protocols) - ├── connection.py # AsyncMqttBridge (event-loop-driven, no threads) - ├── const.py # MQTT/Homie constants + UUID helpers - ├── homie.py # HomieDeviceConsumer (SPAN snapshot builder) - └── models.py # MqttClientConfig, MqttTransport + ├── async_client.py # NullLock + AsyncMQTTClient (HA core pattern) + ├── client.py # SpanMqttClient (transport + control protocols) + ├── connection.py # AsyncMqttBridge (event-loop-driven, no threads) + ├── const.py # MQTT/Homie constants + UUID helpers + └── models.py # MqttClientConfig, MqttTransport + +packages/schema-0/ # distribution: span-panel-api-schema-0 +└── src/span_panel_api_schema_0/ + # Flat parser: HomiePropertyAccumulator, HomieLifecycle, + # HomieDeviceConsumer, field metadata, SCHEMA_ANCHOR + +packages/schema-1/ # distribution: span-panel-api-schema-1 +├── spec/ # eBus capability catalogs, byte-copied; checked against, never parsed +└── src/span_panel_api_schema_1/ + # Parent/child parser: ControllerRoutes, snapshot mapper, + # adoption, catalog validator, spec_lock.json, reference payloads ``` ## Development diff --git a/RELEASE.md b/RELEASE.md index 0810939..9ff1a45 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -30,7 +30,10 @@ The bootstrap and the adapters do not share a version, and this is deliberate ra So `span-panel-api 3.0.0` and `span-panel-api-schema-0 1.0.0` are unrelated numbers, and either can move without the other. -Adapters declare a floor on the bootstrap (`span-panel-api>=3.0.0b1,<4.0`). That dependency is why the versions committed in the manifests are load-bearing: they participate in resolution, so they are not placeholders that a release process may overwrite. +Adapters declare a floor on the bootstrap (`span-panel-api>=3.0.0,<4.0`). That dependency is why the versions committed in the manifests are load-bearing: they participate in resolution, so they are not placeholders that a release process may overwrite. + +Those floors name **stable** versions on purpose. A specifier that names a prerelease is pip's own signal that prereleases are acceptable for that requirement, so a floor left pointing at a beta would leave a released install willing to resolve a future +beta of its sibling without anyone asking for one. ## How a tag selects a distribution @@ -73,7 +76,12 @@ A mismatch is a hard failure with both numbers in the message, so the common mis ## Releasing one distribution -1. **Bump the version** in that distribution's manifest, and add a `CHANGELOG.md` entry (the root one for the bootstrap, `packages/schema-N/CHANGELOG.md` for an adapter). +1. **Bump the version** in that distribution's manifest, and record the change in its `CHANGELOG.md` (the root one for the bootstrap, `packages/schema-N/CHANGELOG.md` for an adapter). + + **Changelogs carry public versions only.** A beta gets no heading of its own: fold its changes into the entry for the public version it is working towards, described against the **last public release** rather than against the beta before it. A fix that + only repairs something an earlier beta broke does not appear at all — from the point of view of somebody upgrading between released versions, it never happened. This keeps the file answering the question a reader actually has ("what changes if I + upgrade?") instead of narrating development. + 2. **Merge to `develop`** (or `main`, once this work is no longer prototype) and let CI go green. 3. **Create a GitHub Release:** - **Tag** — `vX.Y.Z` or `schema-N-vX.Y.Z`, per the table above. @@ -100,9 +108,9 @@ To release the whole workspace: 4. Cut the releases **bootstrap first, then each adapter**: ```text - v3.0.0b1 → span-panel-api - schema-0-v1.0.0b1 → span-panel-api-schema-0 - schema-1-v0.1.0 → span-panel-api-schema-1 + v3.0.0 → span-panel-api + schema-0-v1.0.0 → span-panel-api-schema-0 + schema-1-v1.0.0 → span-panel-api-schema-1 ``` PyPI accepts them in any order, but bootstrap-first means there is never a window in which an adapter is installable and its dependency is not. @@ -160,7 +168,7 @@ CI going green proves the build, not the install. The seam this repository is bu ```bash # 1. The bootstrap alone must fail by name, not with ModuleNotFoundError -python3 -m venv .solo && ./.solo/bin/pip install --pre span-panel-api +python3 -m venv .solo && ./.solo/bin/pip install span-panel-api ./.solo/bin/python -c " from span_panel_api.adapters import installed_adapter_keys, resolve_adapter, DEFAULT_ADAPTER_KEY from span_panel_api.exceptions import SpanPanelAdapterMissingError @@ -172,16 +180,24 @@ except SpanPanelAdapterMissingError as exc: " # 2. Both packages: the adapter resolves through discovery -python3 -m venv .both && ./.both/bin/pip install --pre span-panel-api span-panel-api-schema-0 +python3 -m venv .both && ./.both/bin/pip install "span-panel-api[schema-0]" ./.both/bin/python -c " from span_panel_api.adapters import installed_adapter_keys print('adapters:', installed_adapter_keys()) " + +# 3. The extra is the upgrade path, so check it resolves the adapter too +python3 -m venv .all && ./.all/bin/pip install "span-panel-api[schema-0,schema-1]" +./.all/bin/python -c " +from span_panel_api.adapters import installed_adapter_keys +print('adapters:', installed_adapter_keys()) +" ``` -Expected: `adapters: []` then a named `SpanPanelAdapterMissingError` in the first, `adapters: ['schema_0']` in the second. +Expected: `adapters: []` then a named `SpanPanelAdapterMissingError` in the first, `adapters: ['schema_0']` in the second, and both keys in the third. -Drop `--pre` once the versions being verified are not pre-releases. +Add `--pre` only when the versions being verified are pre-releases. It is not the default verb any more: from 3.0.0 onwards every distribution here publishes stable versions, and no floor in any manifest names a prerelease — which is deliberate, since a +specifier that names one is pip's own signal that prereleases are acceptable for that requirement. ## Pre-releases diff --git a/conftest.py b/conftest.py index 2689752..72a68ee 100644 --- a/conftest.py +++ b/conftest.py @@ -11,7 +11,7 @@ @pytest.fixture -def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]: +def event_loop() -> Generator[asyncio.AbstractEventLoop]: """Provide a new asyncio event loop for each test (for pytest-homeassistant compatibility).""" loop = asyncio.new_event_loop() yield loop diff --git a/packages/schema-0/CHANGELOG.md b/packages/schema-0/CHANGELOG.md index c176950..e5f4035 100644 --- a/packages/schema-0/CHANGELOG.md +++ b/packages/schema-0/CHANGELOG.md @@ -7,64 +7,40 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is fixed — the flat single-device schema, SPAN firmware `r202603` through `r202627` — and is identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version number. A release here means this parser changed, never that the panel did. -## [1.0.0b5] - 08/2026 +Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the last public release, never against the beta before it. -Pre-release. Requires `span-panel-api` 3.0.0b4 or newer, which is the floor the manifest has carried since the bootstrap grew the EVSE charge-limit members. +## [1.0.0] -### Added - -- **`set_evse_charge_limit_topic` and `evse_charge_limit_payload`.** Both required of every adapter, because `SchemaAdapter` gained them: `_derive_required_members` makes each public protocol member mandatory of every adapter wheel, so an adapter without - them is rejected at discovery no matter which panel it would have parsed. Flat firmware publishes no charge-limit surface, so this distribution answers for the absence rather than for a topic — the point being that answering is not optional. -- **`adopted_devices` reports empty.** Adoption is a parent/child idea: a flat panel is one device with no unmodelled children to adopt, so the honest answer is a stable empty tuple rather than an unimplemented member. `set_adopted_property` therefore - raises on a flat panel for the same reason it raises for a device that does not exist, which is what makes the snapshot lookup an authorization rather than a lookup. - -## [1.0.0b3] - 08/2026 - -Pre-release. Requires `span-panel-api` 3.0.0b2 or newer — unchanged, because nothing added here reaches for anything newer. - -### Changed - -- **BREAKING — DER identity is translated into v1.0's vocabulary rather than mirroring flat's names.** `model` is the human designation and `part_number` the SKU, on `battery`, `evse` and `pv` alike; `product_name` is retired on all three. Flat is the - irregular side: it puts the SKU in `bess/model` and in `evse/part-number` — the same concept under two names — and gives PV neither. `schema_1` used to cross over to preserve each entity's displayed meaning, which worked and permanently encoded flat's - irregularity in the snapshot. This adapter now normalises instead: `bess/model` → `part_number`, `bess/product-name` → `model`. **`battery.model` changes value for existing flat users at this upgrade.** Measured: every EVSE identity field now reads - identically on both adapters, so for that device class identity stops being a migration delta at all. - -### Added - -- **`dominant_power_source_payload`.** Flat already speaks this vocabulary, so the value passes through — the method exists because `schema_1` must translate, and a caller should not have to know which schema is underneath. Validated rather than passed - blindly: an unrecognised value returns `None` and the transport refuses the command, matching `schema_1` rather than putting a string outside the enum on the wire. - -## [1.0.0b2] - 08/2026 - -Pre-release. Follows the reshaped `SchemaAdapter` protocol released in `span-panel-api` 3.0.0b2. +First release as a standalone distribution. Requires `span-panel-api` 3.0.0 or newer. ### Added +- **The flat-schema parser, extracted from `span-panel-api` 2.6.4.** Relocated from `span_panel_api._impl.schema_0` to `span_panel_api_schema_0`, and registered as `schema_0` under the `span_panel_api.schema_adapters` entry-point group, which is the only + way `span-panel-api` reaches it — the bootstrap never imports this package. Installing it is what makes flat-schema panels work; `span-panel-api` alone connects and then raises `SpanPanelAdapterMissingError` naming the adapter it could not find. +- **`HomieLifecycle`, `HomiePropertyAccumulator` and `HomieDeviceConsumer` live here now.** All three left the bootstrap because they are flat-schema-specific rather than Homie-convention-level: the accumulator filters every topic against a single device's + prefix and stores `node → prop`, and `HomieLifecycle`'s members are not Homie 5 `$state` values but a consumer-side progression encoding "one description received ⇒ ready". +- **`SCHEMA_ANCHOR`** (`sha256:d347556a07d98f40`, firmware `spanos2/r202603/05`) — the schema revision every hardcoded fact in this package was read from, with `SCHEMA_ANCHOR_FIELD` naming the field it comes from (`typesSchemaHash`). The field is + per-adapter: parent/child firmware renames it to `deviceClassesSchemaHash` along with the block it covers, so `schema_1` declares its own rather than inheriting one that does not exist on its firmware. - **`ADAPTER_CONTRACT = 1`**, declaring which version of the bootstrap-to-adapter contract this parser was built against. Declared as a literal rather than imported from `span_panel_api.protocol`: a value read from the installed bootstrap would agree with every bootstrap, which is exactly the disagreement the check exists to find. - -### Changed - -- **BREAKING: `SchemaZeroAdapter(serial_number, schema)`** replaces `SchemaZeroAdapter(serial_number, panel_size)`, following the protocol change in `span-panel-api`. Panel size is now derived here, by reading the circuit `space` format out of the flat - schema's `types` block — knowledge that belongs to this package rather than to the transport, which was previously doing it on every adapter's behalf. -- **`build_field_metadata()` takes no arguments**, reading the schema this adapter was constructed with. -- **The `span-panel-api` floor is now `>=3.0.0b2`.** `1.0.0b1` declared `>=3.0.0b1`, which admitted a bootstrap that constructs adapters with `panel_size` — a pairing that could not work. Installing that combination now fails by name at discovery rather - than on argument count inside the transport, but the floor is what stops a resolver reaching it at all. - -## [1.0.0b1] - 08/2026 - -Pre-release. First release as a standalone distribution. - -### Added - -- **The flat-schema parser, extracted from `span-panel-api` 2.6.4.** Relocated verbatim from `span_panel_api._impl.schema_0` to `span_panel_api_schema_0`; only import statements changed. Registers itself as `schema_0` under the - `span_panel_api.schema_adapters` entry-point group, which is the only way `span-panel-api` reaches it — the bootstrap never imports this package. -- **`SCHEMA_ANCHOR`** (`sha256:d347556a07d98f40`, firmware `spanos2/r202603/05`) — the schema revision every hardcoded fact in this package was read from, with `SCHEMA_ANCHOR_FIELD` naming the field it comes from (`typesSchemaHash`). The field is - per-adapter: parent/child firmware renames it to `deviceClassesSchemaHash` along with the block it covers, so a future `schema_1` declares its own rather than inheriting one that does not exist on its firmware. +- **`dominant_power_source_payload`.** Flat already speaks this vocabulary, so the value passes through — the method exists because `schema_1` must translate, and a caller should not have to know which schema is underneath. Validated rather than passed + blindly: an unrecognised value returns `None` and the transport refuses the command, matching `schema_1` rather than putting a string outside the enum on the wire. +- **`set_evse_charge_limit_topic` and `evse_charge_limit_payload`.** Both are required of every adapter, because `_derive_required_members` makes each public protocol member mandatory of every adapter wheel — an adapter without them is rejected at + discovery no matter which panel it would have parsed. Flat firmware publishes no charge-limit surface, so this distribution answers for the absence rather than for a topic; the point is that answering is not optional. +- **`adopted_devices` reports empty.** Adoption is a parent/child idea: a flat panel is one device with no unmodelled children to adopt, so the honest answer is a stable empty tuple rather than an unimplemented member. `set_adopted_property` therefore + raises on a flat panel for the same reason it raises for a device that does not exist, which is what makes the snapshot lookup an authorization rather than a lookup. +- **Panel size is derived here**, by reading the circuit `space` format out of the flat schema's `types` block — knowledge that belongs to this package rather than to the transport, which previously did it on every adapter's behalf. - **Provenance tests** asserting that all 64 hardcoded `(node_type, property_id)` pairs still resolve against the captured schema, that `HOMIE_DOMAIN` / `HOMIE_VERSION` still match it, and that the two lugs subtypes real firmware publishes remain absent from the schema _and_ present in the metadata alias table. This is the only signal that catches schema drift before release; every other symptom reaches production as a silent absence. - **A `py.typed` marker**, so consumers type-check against this package's real annotations rather than resolving everything it exports as `Any`. +### Changed + +- **BREAKING — DER identity is translated into the parent/child vocabulary rather than mirroring flat's names.** `model` is the human designation and `part_number` the SKU, on `battery`, `evse` and `pv` alike; `product_name` is retired on all three. Flat + is the irregular side: it puts the SKU in `bess/model` and in `evse/part-number` — the same concept under two names — and gives PV neither. Mirroring that would have permanently encoded flat's irregularity in the snapshot, so this adapter normalises + instead: `bess/model` → `part_number`, `bess/product-name` → `model`. **`battery.model` changes value for existing flat users at this upgrade.** Measured: every EVSE identity field now reads identically on both adapters, so for that device class identity + stops being a migration delta at all. + ### Known deviations from the published schema - **Circuit `active-power` is treated as watts, though the schema declares kilowatts.** Real panels publish watts; this was established against live hardware and the 1000× correction was removed accordingly. A test asserts the schema still says `kW`, so diff --git a/packages/schema-0/README.md b/packages/schema-0/README.md index 9a8abc3..68b5e14 100644 --- a/packages/schema-0/README.md +++ b/packages/schema-0/README.md @@ -13,7 +13,7 @@ support for a new panel schema by installing a package rather than by upgrading ## Installation ```console -pip install span-panel-api span-panel-api-schema-0 +pip install "span-panel-api[schema-0]" ``` Installing this package is what makes flat-schema panels work. `span-panel-api` on its own will connect and then raise `SpanPanelAdapterMissingError` naming the adapter it could not find. @@ -21,10 +21,11 @@ Installing this package is what makes flat-schema panels work. `span-panel-api` A consumer that wants to support panels on either schema installs both adapters: ```console -pip install span-panel-api span-panel-api-schema-0 span-panel-api-schema-1 +pip install "span-panel-api[schema-0,schema-1]" ``` -Dispatch happens at runtime, per panel, from the `data-model-version` the panel reports. +Dispatch happens at runtime, per panel, from the `data-model-version` the panel reports. The extras are the recommended spelling because they give `pip install -U` a correct upgrade path — the dependency arrow runs from adapter to bootstrap, so upgrading +the bootstrap alone would otherwise leave a stale adapter wheel that discovery then rejects, with pip reporting success. Naming the distributions directly works too. ## Retirement diff --git a/packages/schema-0/pyproject.toml b/packages/schema-0/pyproject.toml index e50343e..2562a46 100644 --- a/packages/schema-0/pyproject.toml +++ b/packages/schema-0/pyproject.toml @@ -1,15 +1,18 @@ [project] name = "span-panel-api-schema-0" -version = "1.0.0b5" +version = "1.0.0" description = "Flat-schema (data-model-version absent) parser for span-panel-api" authors = [ {name = "SpanPanel"} ] readme = "README.md" license = "MIT" -requires-python = ">=3.10,<4.0" +requires-python = ">=3.14,<4.0" dependencies = [ - "span-panel-api>=3.0.0b4,<4.0", + # Stated as a stable version rather than as the prerelease the floor tracked + # during development: naming a prerelease in a specifier is pip's own signal + # that prereleases are acceptable for that requirement. + "span-panel-api>=3.0.0,<4.0", ] [project.urls] diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index c62614c..5f9bcb7 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -7,184 +7,111 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is the parent/child device tree SPAN firmware `r202633+` publishes, identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version number. A release here means this parser changed, never that the panel did. -## [0.1.0b10] - 08/2026 +Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the last public release, never against the beta before it. -Pre-release. Requires `span-panel-api` 3.0.0b12 or newer — unchanged. +## [1.0.0] -### Fixed - -- **The two lugs devices are two extension subjects, not one.** 0.1.0b9 paired both with `ExtensionSubject(kind="panel")` and no instance key, on the reasoning that their curated fields land in the panel snapshot. But a subject is an _identity_: a consumer - keys an entity on `(kind, instance_key, node/property)`, so two lugs devices declaring the same vendor property produced one identity for two readings — whichever sorted first won, and the other was dropped. Identical firmware on both lugs makes that the - expected case rather than a coincidence, not something a vendor would have to do oddly to hit. They are now `kind="lugs"` with `upstream`/`downstream` as the instance key, matched on `info/direction` for the reason `find_lugs` documents: the reference - tree's ids are the simulator's naming, and the direction property is what the schema defines. A lugs device declaring no direction is left unpaired rather than keyed on something unstable — its properties stay in discovery, which is where an - unidentifiable device belongs. - -Pre-release. **Requires `span-panel-api` 3.0.0b12 or newer** — the floor moved, because this parser now imports `ExtensionProperty` and `ExtensionSubject` and constructs a snapshot with `extension_properties`. +First release as a standalone distribution, and the first parser for the parent/child data model. Requires `span-panel-api` 3.0.0 or newer, and `ebus-sdk` `>=0.19,<0.24`. ### Added -- **Vendor properties on modelled devices are emitted with their values.** Every property a modelled device declares that this adapter maps to no snapshot field — excluding `info` and `connection`, which resolve to the device card and the tree — now - arrives as an `ExtensionProperty` carrying its subject, its declaration and its retained value. A battery vendor hanging `battery-2/cell-temperature` off the BESS previously reached a consumer nowhere. -- **`node_has_curated_siblings`**, one bit per row: whether this adapter reads any _other_ property of the same node. A vendor extending `meter` is probably extending the meter, and that is the whole of what the bit says — which fields are read stays - internal. - -### Changed +#### The adapter -- **`addressed_rows()` is extracted from `build_discovery`**, so the discovery rows and the extension rows cannot disagree about what "unaddressed" means. A property counted as addressed by one and not the other would either appear as an entity the - diagnostics claim is ignored, or be reported ignored while a consumer renders it — each reading as a defect in whichever surface disagreed. - -## [0.1.0b8] - 08/2026 - -Pre-release. Requires `span-panel-api` 3.0.0b4 or newer — unchanged. +- **`SchemaOneAdapter`**, registered as `schema_1` under the `span_panel_api.schema_adapters` entry-point group. A panel reporting `data-model-version` `1.x` resolves to it; a panel without this package installed still gets the named + `SpanPanelAdapterMissingError`, so installing it is the opt-in. +- **`ADAPTER_CONTRACT = 1`**, declaring which version of the bootstrap-to-adapter contract this parser was built against. Declared as a literal rather than imported from `span_panel_api.protocol`: a value read from the installed bootstrap would agree with + every bootstrap, which is exactly the disagreement the check exists to find. +- **`ControllerRoutes`** — an `ebus_sdk.MqttControllerTransport` that records `Controller`'s subscriptions instead of making them, so the SDK parses the tree over span-panel-api's own connection to the panel's broker. The adapter is built before a + connection exists and never receives one; a single wildcard subscription made by the transport layer covers the whole tree, and this routes each message to whichever SDK callback asked for it. +- **The snapshot mapper.** Sorts the tree by declared device type — never by device id — and maps it onto `SpanPanelSnapshot`: circuits, both lugs, the MID, and the BESS/PV/EVSE devices. +- **Retained messages are held until their route exists.** `Controller` learns its topics as it walks the tree, but one subscription delivers the whole tree at once in whatever order the broker replays its retained store; seeded children-first, a 40-space + panel would otherwise parse as zero circuits. Unrouted messages are held and released when the matching route appears — the value a per-device subscription would have been given at subscribe time — with a ceiling so an unclaimed subtree cannot leak. +- **Readiness asks about every declared device, at any depth**, not only the root, so a connection cannot complete with a fraction of its circuits and no panel size. Child _state_ is deliberately not required, so an offline DER does not block a connection; + the model is required only when the root's description declares it. +- **Panel size from `info/model`** via `PANEL_SIZE_BY_MODEL`, which is what restores the unmapped-position entries a consumer builds from the difference between total and occupied spaces. `info/spaces` has no format and the panel publishes no size + property, so the model is the only source; `panel_model_drift()` reports a model the panel declares that we have no size for, because the alternative is a user noticing missing positions. +- **Field metadata read from each device's `$description`** rather than a schema document. The same capability type exposes different properties on different device classes — `meter` is voltage on the panel, power and energy on a circuit, both currents on + lugs — so the per-device description is what this panel actually has. +- **A `py.typed` marker**, so consumers type-check against this package's real annotations. -### Fixed +#### Mapping decisions worth knowing -- **`dominant_power_source` reports `GRID` on a panel with no MID, instead of nothing.** The field's source moved in v1.0: flat published a closed enum of source classes on the panel, v1.0 names the forming device on the MID's `grid` node. A panel with no - battery has no MID, so the property has no publisher and the field went `None` — observed on a live install that read `Grid` on flat all night and went unknown the moment it upgraded, with nothing about the site having changed. +- **`grid_state` reads the MID's `grid/islanding-state`, not its `grid/grid-state`.** The MID publishes both: `islanding-state` is `ON_GRID`/`OFF_GRID`/`UNKNOWN`, and `grid-state` is `UP`/`DOWN`/`DEGRADED`/`UNKNOWN`. Flat's `grid_state` was the BESS's + `grid-state`, an islanding answer, so its successor is `islanding-state`; matching on the property name rather than the value set would put `UP` where a consumer expects `ON_GRID` — an entity keeping its id and history while its vocabulary silently + changed. `grid/grid-state` asks whether the utility supply is healthy, is new in v1.0 with no flat equivalent, and is left unmapped as a new signal rather than a replacement. +- **`dominant_power_source` reports `GRID` on a panel with no MID**, rather than nothing. The field's source moved: flat published a closed enum of source classes on the panel, and v1.0 names the forming device on the MID's `grid` node — so a panel with no + battery has no MID, the property has no publisher, and the field would go `None`. Observed on a live install that read `Grid` on flat all night and went unknown the moment it upgraded, with nothing about the site having changed. A missing MID settles the answer by elimination rather than leaving it open. `BATTERY` needs a BESS and a BESS brings a MID; `PV` cannot form a grid alone, because anything that can is a grid-forming inverter and therefore a MID; `NONE` describes a panel supplying nothing, which is a panel that is not publishing. What remains is a generator, and that is two cases of which only one reaches here: a generator wired through a MID is named by that MID and answered before this point, while a generator with no MID interface is what SPAN treats as the grid — and it is the only kind an install with no MID can have. The elimination therefore keeps holding if MID-integrated generators arrive, because they bring a MID. A site running off-grid without storage is not - a counterexample — it goes dark at sunset. + a counterexample: it goes dark at sunset. This deliberately does not follow `resolve_islanding_state`, which refuses the same shortcut, and the counterexample that defeats it there is what supports it here: a generator-fed island **is** islanded, so inferring on-grid from a missing MID would be - wrong, while its grid-forming entity really is what SPAN calls the grid. It is also no worse than flat, which could not see an uninterfaced generator either and published `GRID` regardless. - - A MID that exists and has not answered still reports nothing. That is genuinely unknown, and distinct from there being no islanding authority at all. - -## [0.1.0b7] - 08/2026 - -Pre-release. Requires `span-panel-api` 3.0.0b4 or newer — unchanged. - -### Changed - -- **The eBus SDK ceiling moves to `<0.24`.** 0.23.0 and 0.23.1 both shipped the same day the previous `<0.23` bound was set, so that bound excluded the current release on its first day. Re-checked rather than extrapolated: diffing the 0.22.0 and 0.23.1 - wheels module by module, exactly two files change — `__init__.py`, by the version string alone, and `declaration.py`, the declarative builder. This distribution's whole SDK surface is `Controller`, `homie.DiscoveredDevice` and structural conformance to - `MqttControllerTransport`, and nothing here imports `declaration`. The suite is green against 0.23.1 with no source change. - -## [0.1.0b6] - 08/2026 - -Pre-release. Requires `span-panel-api` 3.0.0b4 or newer — unchanged. The eBus SDK ceiling tightens to `<0.23`, the versions actually tested: 0.x carries no compatibility contract, and 0.22 was read module by module before the bound was set — it changes -only publisher-side code, leaving `adapter.py`, `topology.py`, `transport.py` and `property.py` byte-identical to 0.21. + wrong, while its grid-forming entity really is what SPAN calls the grid. A MID that exists and has not answered still reports nothing — that is genuinely unknown, and distinct from there being no islanding authority at all. -### Fixed +- **`battery.power_w` is discharge-positive.** The enclosure meters the BESS the way it meters a circuit it feeds, so a discharging battery publishes a negative `meter/active-power` and the mapper negates it. Positive therefore means power flowing _out of_ + the battery, which is the eBus rule for a device's own meter. It stays deliberately opposite to `panel.power_flow_battery`, the enclosure's arbitrated figure, which is passed through untouched by both adapters and is charge-positive. The two are the same + physical power in different frames, and a consumer rendering both negates one of them. -- **The BESS meter is discharge-positive, and was named for the opposite.** `_charge_positive` is renamed `_discharge_positive`. No published value changes — the negation was always right — but the name asserted a direction that the wire does not carry, - and the root changelog documented that wrong direction as fact. Settled by measurement rather than by reading: with the producer in self-consumption and the grid at exactly zero, `pv −4181.34 + battery −1917.49 + grid −0.0 + site +6098.83 = 0`, so the - battery was discharging at 1917 W and `battery.power_w` reported `+1917.49`. The convention matches the eBus rule for a device's own meter: positive is power flowing out of the device. - - It stays deliberately opposite to `panel.power_flow_battery`, which is the enclosure's arbitrated figure, passed through untouched and charge-positive. The two are the same physical power in different frames, and a consumer rendering both negates one of - them. - -### Added +#### Adoption and vendor extensions - **`adoption`, building `AdoptedDevice` records for device types this parser does not model**, with `set_topic` populated only where the declaration says the property is settable. Subtype-aware, so a curated device never lands in `adopted_devices`. +- **Vendor properties on modelled devices are emitted with their values.** Every property a modelled device declares that this adapter maps to no snapshot field — excluding `info` and `connection`, which resolve to the device card and the tree — arrives as + an `ExtensionProperty` carrying its subject, its declaration and its retained value. A battery vendor hanging `battery-2/cell-temperature` off the BESS would otherwise reach a consumer nowhere. +- **The two lugs devices are two extension subjects, not one.** A subject is an _identity_: a consumer keys an entity on `(kind, instance_key, node/property)`, so pairing both lugs with a single subject would give one identity for two readings, and + identical firmware on both lugs makes that the expected case rather than a coincidence. They are `kind="lugs"` with `upstream`/`downstream` as the instance key, matched on `info/direction` for the reason `find_lugs` documents: the reference tree's ids + are the simulator's naming, and the direction property is what the schema defines. A lugs device declaring no direction is left unpaired rather than keyed on something unstable — its properties stay in discovery, which is where an unidentifiable device + belongs. +- **`node_has_curated_siblings`**, one bit per row: whether this adapter reads any _other_ property of the same node. A vendor extending `meter` is probably extending the meter, and that is the whole of what the bit says — which fields are read stays + internal. `addressed_rows()` is shared with `build_discovery`, so the discovery rows and the extension rows cannot disagree about what "unaddressed" means. -## [0.1.0b5] - 08/2026 - -Pre-release. Requires `span-panel-api` 3.0.0b4 or newer — unchanged, because nothing added here reaches for anything newer. - -### Added - -- **`span_panel_api_schema_1.reference_payloads`, shipping `parent_child_tree.json` as package data.** The captured retained-topic tree of a full 40-space panel moves out of the repository's `tests/fixtures/` and into the wheel, reached by - `parent_child_tree()` rather than by path. The reason is the same one that put the schema document in the bootstrap's wheel: consumers outside this repository need a real capture to check an adapter's output against, and the only alternative to shipping - one is vendoring a byte copy that has no version and goes stale in silence. It ships from _this_ distribution rather than the bootstrap because a retained topic tree is only interpretable by the parser that speaks its vocabulary — and the eBus SDK that - turns it back into devices is this distribution's dependency alone. -- **`devices_from_tree` and `device_from_topics`.** A tree is not directly usable: every consumer has to replay the retained topics through `DiscoveredDevice` first, and that replay is this parser's own knowledge of how the transport feeds it. Shipping the - capture without the replay would just move a copy of that logic into every consumer, which is the burden the package data exists to remove — four test modules here held the same twelve lines, and the Home Assistant integration held a fifth copy with a - comment naming the test it was mirrored from. `devices_from_tree` takes the tree rather than reading it, so a consumer can filter the capture first — dropping the BESS to model a panel that has none — and still build devices the same way. - -## [0.1.0b3] - 08/2026 - -Pre-release. **Requires `span-panel-api` 3.0.0b3 or newer** — see Fixed. - -### Added +#### Conformance against the specification and the producer -- **Spec conformance checking.** `spec_lock.json` ships with the package and records what this parser targets: the firmware range, the eBus specification commit its vocabulary was read from, and the version of every capability, device and registry it - implements. It is the consumer counterpart to the simulator's publisher lockfile, and both are pinned to the same specification commit — though the anchor shared between them is the **firmware range**, not that commit, because the specification says what - a device class _may_ publish while a panel publishes one specific tree. -- **The 13 capability catalogs this adapter addresses**, byte-copied under `spec/` along with the device-types registry. Vendored rather than depended on because the specification is a git repository of versioned documents, not a package. They exist to be +- **`spec_lock.json` ships with the package** and records what this parser targets: the firmware range, the eBus specification commit its vocabulary was read from, and the version of every capability, device and registry it implements. It is the consumer + counterpart to the simulator's publisher lockfile, and both are pinned to the same specification commit — though the anchor shared between them is the **firmware range**, not that commit, because the specification says what a device class _may_ publish + while a panel publishes one specific tree. +- **The capability catalogs this adapter addresses are byte-copied under `spec/`**, along with the device-types registry. Vendored rather than depended on because the specification is a git repository of versioned documents, not a package. They exist to be checked against, never parsed in production: units and datatypes still come from each device's `$description`, since the catalog is the superset across all hardware rather than a statement about the panel in front of us. Formatting hooks are excluded from `spec/`, because a lint fix there would quietly invalidate the byte comparison that makes the copies worth having. -- **`tests/test_schema_one_conformance.py`**, which asks the consumer's question rather than the publisher's. A publisher asks whether everything it emits is legal, and for it an omission is unremarkable. This asks whether every name the adapter _reads_ is - one the specification defines — because a consumer addressing a name that no longer exists does not fail, it goes quiet: the property never arrives, metadata lookup returns `None`, and an entity disappears. `ebus-sdk` 0.18.0 removing the `battery` - capability key in favour of `soc`, with no alias, is exactly that shape. -- **An explicit SPAN extension allowlist.** Fourteen of the forty-two properties this adapter reads are absent from every catalog — per-phase meter readings, panel link states, circuit `spaces`, the EVSE surface. All are legal, since the specification - permits properties it has never heard of. They are enumerated with reasons so that a name missing from the catalog must be a deliberate claim about SPAN's vocabulary rather than an unnoticed typo; at runtime the two are indistinguishable. Tests also fail - when an extension is later adopted upstream, or when one is declared for a property nothing reads. -- **A peer record and simulator coverage check.** `spec_lock.json` now records the producer this parser is developed against — the SPAN simulator, `role: publisher` — with the specification commit and firmware range it pins, and a captured copy of the tree - it publishes is vendored alongside the catalogs. Two sides reading different vocabularies is now a test failure rather than something noticed later, and the anchor asserted between them is the **firmware range**, since the specification says what a - device class may publish while a panel publishes one specific tree. -- **An explicit record of what the producer does not exercise.** Of the 42 `(capability, property)` pairs this adapter reads, the simulator's captured tree declares 41. The exception is `grid/islanding-state`: the simulator models a MID but its tracked - config publishes none, so `grid_state` — corrected in `0.1.0b2` to read `islanding-state` rather than `grid-state` — is the single mapping the producer gives no evidence for. Recorded rather than left implicit, because a passing suite otherwise reads as - coverage it does not have. The entry is rejected once the simulator starts publishing it. -- **The parser is now driven end to end from what the producer actually publishes.** Every other test in this package runs on a fixture captured off the upstream _generic_ eBus panel simulator, which by construction never carries SPAN's own vocabulary. - `spec/fixtures/simulator_wire.json` is a capture from SPAN's publisher instead — descriptions, `$state` and all 494 property values across 37 devices — fed in sorted topic order, the way a retained store replays it rather than the way a tree is walked. - The parser reaches ready on it, sizes the panel from `MAIN_40`, and parses all 30 circuits. Values are deliberately not asserted: the producer's config carries `noise_factor` and its clock advances, so pinning a wattage would fail on every recapture for - a reason nobody could act on. -- **Two producer-side gaps are pinned rather than left to be noticed.** `grid_state` stays `None` because nothing instantiates a MID, and every DER — BESS, PV and both EVSEs — declares `info/model` in its `$description` and never publishes a value (PV - declares five `info` properties and publishes one). The second breaks the single standing obligation eBus places on a publisher, to declare accurately what it publishes, and is invisible to a conformance checker: comparing declarations against catalogs - cannot see a declaration nothing fulfils. Only a capture carrying values can, which is the argument for this fixture existing. Both are asserted as current expectations, so closing either fails the test that describes it. - -Provenance (byte comparison against a specification or simulator checkout) is skipped unless `EBUS_SPEC_DIR` / `PANELBENCH_DIR` are set, so conformance and coverage run everywhere while the byte checks stay opportunistic. The wire capture is compared on -shape rather than bytes for the same reason its values are not asserted. Provenance proves the right bytes were copied; it cannot prove they were understood, which is what the other two are for. - -### Fixed - -- **The conformance check was reading the wrong set of names.** Built from `_PROPERTY_FIELD_MAP` alone, it covered only properties that carry field metadata and silently skipped everything the snapshot mapper reads directly — the MID, `connection` - feeds/fed-by, `info/direction`. `grid_state`, the most recently corrected mapping in this package, was among them. The read set is now derived from the source itself, so it cannot fall behind the code; that immediately surfaced `info/direction` as a - fifteenth undeclared extension. -- **The bootstrap floor is raised to 3.0.0b3**, which is where it should always have been: this parser imports `SpanMidSnapshot`, and 3.0.0b2 does not define it. The declared `>=3.0.0b2` let a resolver pair this wheel with 3.0.0b2 and fail on import. - Caught before the first release that would have shipped it. `schema-0` keeps its b2 floor; every name it imports is present there, checked rather than assumed. - -## [0.1.0b2] - 08/2026 - -Pre-release. Corrects the dependency floor `0.1.0b1` shipped with, and follows the reshaped `SchemaAdapter` protocol released in `span-panel-api` 3.0.0b2. - -### Added - -- **`ADAPTER_CONTRACT = 1`**, declaring which version of the bootstrap-to-adapter contract this parser was built against. Declared as a literal rather than imported from `span_panel_api.protocol`: a value read from the installed bootstrap would agree with - every bootstrap, which is exactly the disagreement the check exists to find. - -### Fixed - -- **The `span-panel-api` floor was `>=3.0.0b1`, which no published bootstrap could satisfy in practice.** `0.1.0b1` was built against a bootstrap that reads the panel's `data-model-version` and constructs adapters with the whole schema; the only bootstrap - on PyPI at the time did neither. Its `V2HomieSchema` had no `data_model_version` field at all, so a `1.x` panel could not even be represented, and its factory hardcoded the version to `None` — meaning this adapter was discoverable and never selectable. - The floor is now `>=3.0.0b2`, the first release where both hold. Nothing was installed against the old floor; the combination was unreachable rather than broken in the field. - -## [0.1.0b1] - 08/2026 - -Pre-release. First release as a standalone distribution, and the first parser for the parent/child data model. - -### Added - -- **`SchemaOneAdapter`**, registered as `schema_1` under the `span_panel_api.schema_adapters` entry-point group. A panel reporting `data-model-version` `1.x` resolves to it; a panel without this package installed still gets the named - `SpanPanelAdapterMissingError`, so installing it is the opt-in. -- **`ControllerRoutes`** — an `ebus_sdk.MqttControllerTransport` that records `Controller`'s subscriptions instead of making them, so the SDK parses the tree over span-panel-api's own connection to the panel's broker. The adapter is built before a - connection exists and never receives one; a single wildcard subscription made by the transport layer covers the whole tree, and this routes each message to whichever SDK callback asked for it. -- **The snapshot mapper.** Sorts the tree by declared device type — never by device id — and maps it onto `SpanPanelSnapshot`: circuits, both lugs, the MID, and the BESS/PV/EVSE devices. -- **Panel size from `info/model`** via `PANEL_SIZE_BY_MODEL`, which is what restores the unmapped-position entries the integration builds from the difference between total and occupied spaces. `info/spaces` has no format and the panel publishes no size - property, so the model is the only source; `panel_model_drift()` reports a model the panel declares that we have no size for, because the alternative is a user noticing missing positions. -- **Field metadata read from each device's `$description`** rather than a schema document. The same capability type exposes different properties on different device classes — `meter` is voltage on the panel, power and energy on a circuit, both currents on - lugs — so the per-device description is what this panel actually has. -- **A `py.typed` marker**, so consumers type-check against this package's real annotations. +- **A conformance suite that asks the consumer's question rather than the publisher's.** A publisher asks whether everything it emits is legal, and for it an omission is unremarkable. This asks whether every name the adapter _reads_ is one the + specification defines — because a consumer addressing a name that no longer exists does not fail, it goes quiet: the property never arrives, metadata lookup returns `None`, and an entity disappears. The read set is derived from the source itself rather + than from the metadata table alone, so it cannot fall behind the code. +- **An explicit SPAN extension allowlist.** A number of the properties this adapter reads are absent from every catalog — per-phase meter readings, panel link states, circuit `spaces`, `info/direction`, the EVSE surface. All are legal, since the + specification permits properties it has never heard of. They are enumerated with reasons so that a name missing from the catalog must be a deliberate claim about SPAN's vocabulary rather than an unnoticed typo; at runtime the two are indistinguishable. + Tests also fail when an extension is later adopted upstream, or when one is declared for a property nothing reads. +- **The catalogs are used as a validator, not just as a vocabulary list.** `span_panel_api_schema_1.catalog` compares a declared `unit` or `datatype` against the catalog's definition of the same property, which is the comparison that catches a mislabel. + Agreement is silence; disagreement is surfaced, never silently resolved — a finding is not a licence to change a wire reader to match the catalog, nor to assume the catalog is right, since both sides have been wrong. Divergences are recorded with what + the wire says, what the catalog says, which producers show it, a reason and a date, and the baseline fails in **both** directions: a new divergence fails until somebody records it, and a recorded divergence that has disappeared fails until its line is + removed. That second direction is what keeps the register self-cleaning rather than a suppression list. +- **An abstract unit is a dimension, and comparing it as a string would report conformance as the defect.** `soc/soe`, `soc/total-energy-storage`, `soc/loadup-headroom` and `info/nameplate-capacity` are all `unit: "energy"`, which the specification + requires a publisher to substitute a real unit for — a BESS in kWh, a water heater in Wh. `UNIT_FAMILIES` enumerates membership rather than deriving it from an SI-prefix rule, so a member is silent, echoing the placeholder back is a finding, and an + energy unit nobody enumerated is a question for a human. +- **A peer record and a producer coverage check.** `spec_lock.json` records the producer this parser is developed against — the SPAN simulator, `role: publisher` — and a capture of the tree it publishes is vendored alongside the catalogs, so two sides + reading different vocabularies is a test failure rather than something noticed later. Of the `(capability, property)` pairs this adapter reads, the capture declares all but `grid/islanding-state`: the simulator models a MID but its tracked config + publishes none. That gap is recorded rather than left implicit, because a passing suite otherwise reads as coverage it does not have, and the entry is rejected once the simulator starts publishing it. +- **The parser is driven end to end from what the producer actually publishes.** `spec/fixtures/simulator_wire.json` is a capture from SPAN's publisher — descriptions, `$state` and all property values across every device — fed in sorted topic order, the + way a retained store replays it rather than the way a tree is walked. The parser reaches ready on it, sizes the panel from `MAIN_40`, and parses all 30 circuits. Values are deliberately not asserted: the producer's config carries `noise_factor` and its + clock advances, so pinning a wattage would fail on every recapture for a reason nobody could act on. +- **Provenance is opportunistic; conformance is not.** Byte comparison against a specification or simulator checkout is skipped unless `EBUS_SPEC_DIR` / `PANELBENCH_DIR` are set, so conformance and coverage run everywhere while the byte checks stay + opportunistic — and CI sets both, so a skip there is a failure. Provenance proves the right bytes were copied; it cannot prove they were understood, which is what the other two are for. + +#### Reference payloads + +- **`span_panel_api_schema_1.reference_payloads`, shipping `parent_child_tree.json` as package data.** The captured retained-topic tree of a full 40-space panel is reached by `parent_child_tree()` rather than by path. Consumers outside this repository need + a real capture to check an adapter's output against, and the only alternative to shipping one is vendoring a byte copy that has no version and goes stale in silence. It ships from _this_ distribution rather than the bootstrap because a retained topic + tree is only interpretable by the parser that speaks its vocabulary — and the eBus SDK that turns it back into devices is this distribution's dependency alone. +- **`devices_from_tree` and `device_from_topics`.** A tree is not directly usable: every consumer has to replay the retained topics through `DiscoveredDevice` first, and that replay is this parser's own knowledge of how the transport feeds it. Shipping the + capture without the replay would just move a copy of that logic into every consumer. `devices_from_tree` takes the tree rather than reading it, so a consumer can filter the capture first — dropping the BESS to model a panel that has none — and still + build devices the same way. ### Known deviations and deliberate gaps - **`set_dominant_power_source_topic()` returns `None`.** The v1.0 property split into `grid-forming-entity` and `asserted-islanding-state`, which are different controls on different devices rather than a rename. `None` makes the transport reject the command instead of publishing where nothing listens; which successor to expose is a product decision. -- **`dsm_state`, `current_run_config`, `grid_islandable` and `pv.relative_position`** have no direct v1.0 equivalent and are left to the product decisions tracked separately. Fields the mapper declines carry no metadata row, so the integration never - validates against a field nothing populates. - -### Fixed before first release - -Both found by verifying reconnect against a live broker, and both presented as a healthy connection. - -- **Messages arriving before the SDK registered a route for them were dropped.** `Controller` learns its topics as it walks the tree, but one subscription delivers the whole tree at once in whatever order the broker replays its retained store. Seeded - children-first, a 40-space panel parsed as zero circuits. Unrouted messages are now held and released when the matching route appears — the value a per-device subscription would have been given at subscribe time — with a ceiling so an unclaimed subtree - cannot leak. -- **Readiness asked only about the root**, so a connection completed with a fraction of its circuits and no panel size. It now waits for every declared device to describe itself, at any depth. Child _state_ is deliberately not required, so an offline DER - does not block a connection; the model is required only when the root's description declares it. -- **`grid_state` read the wrong one of the MID's two grid properties.** The MID publishes both `grid/islanding-state` (`ON_GRID`/`OFF_GRID`/`UNKNOWN`) and `grid/grid-state` (`UP`/`DOWN`/`DEGRADED`/`UNKNOWN`). The flat schema's `grid_state` was the BESS's - `grid-state`, an islanding answer, so its successor is `islanding-state`; `grid/grid-state` asks whether the utility supply is healthy and is new in v1.0 with no flat equivalent. Matching on the property name rather than the value set put `UP` where a - consumer expects `ON_GRID` — an entity keeping its id and history while its vocabulary silently changed. `grid/grid-state` is left unmapped, being a new signal rather than a replacement for an existing field. +- **`pv.relative_position` has no v1.0 equivalent** and is left to the product decisions tracked separately. Fields the mapper declines carry no metadata row, so a consumer never validates against a field nothing populates. +- **`grid_islandable` returns `None` until something publishes it.** It maps to `grid-forming/capable` over the BESS's inverter children, as the disjunction — a panel does not island, its DER does. No producer publishes it today, which is recorded rather + than worked around; `None` keeps absence a gap instead of a claim. +- **Two producer-side gaps are pinned rather than left to be noticed.** `grid_state` stays `None` on the captured tree because nothing instantiates a MID, and every DER — BESS, PV and both EVSEs — declares `info/model` in its `$description` and never + publishes a value. The second breaks the single standing obligation eBus places on a publisher, to declare accurately what it publishes, and is invisible to a conformance checker: comparing declarations against catalogs cannot see a declaration nothing + fulfils. Only a capture carrying values can, which is the argument for that fixture existing. Both are asserted as current expectations, so closing either fails the test that describes it. diff --git a/packages/schema-1/README.md b/packages/schema-1/README.md index 9ac4b51..10a94b8 100644 --- a/packages/schema-1/README.md +++ b/packages/schema-1/README.md @@ -1,11 +1,52 @@ # span-panel-api-schema-1 -Parent/child schema parser (`data-model-version` 1.x, SPAN firmware r202633+) for [span-panel-api](https://github.com/SpanPanel/span-panel-api). +The **parent/child** schema parser for [`span-panel-api`](https://github.com/SpanPanel/span-panel-api): the multi-device Homie tree published by SPAN firmware `r202633+`, which reports `data-model-version` `1.x`. -**Status: incomplete.** This distribution does not yet register a `schema_1` adapter, so installing it does not make a parent/child panel work. A 1.x panel still raises `SpanPanelAdapterMissingError` naming `schema_1`, which is the honest answer until the -parser can build a snapshot. +## Why this is a separate distribution -What exists today is `BridgeControllerTransport` — an `ebus_sdk.MqttControllerTransport` backed by span-panel-api's own MQTT connection, so the eBus SDK can parse the parent/child tree while the connection to the panel's broker stays ours. +`span-panel-api` is a transport and a dispatcher. It knows how to connect to a panel's MQTT broker, route messages, and choose a parser — but it contains no parsing code. Each wire format ships as its own distribution and registers itself under the +`span_panel_api.schema_adapters` entry-point group, so the bootstrap never imports this package until a panel asks for it by name. + +The split matters more here than anywhere else in the workspace: this parser depends on the [eBus SDK](https://github.com/electrification-bus/python-sdk) to turn the tree back into devices, and that dependency is this distribution's alone. A flat-panel +install never pulls it in. + +## Installation + +```console +pip install "span-panel-api[schema-1]" +``` + +Installing this package is what makes parent/child panels work. `span-panel-api` on its own will connect and then raise `SpanPanelAdapterMissingError` naming the adapter it could not find. + +A consumer that wants to support panels on either schema installs both adapters, and dispatch happens at runtime, per panel, from the `data-model-version` the panel reports: + +```console +pip install "span-panel-api[schema-0,schema-1]" +``` + +## What it parses + +`SchemaOneAdapter` maps the device tree onto the same `SpanPanelSnapshot` the flat adapter produces — circuits, both lugs devices, the BESS, PV and EVSE — plus the surface that only exists under the parent/child model: + +- **The MID** (`SpanPanelSnapshot.mid`). The enclosure model puts the `grid` capability on a Microgrid Interconnect Device rather than on the enclosure, so islanding state, grid state and the grid-forming entity live there. +- **Adopted devices** (`SpanPanelSnapshot.adopted_devices`). A device type this parser models nothing for is reported whole — identity and readings — rather than dropped. The schema is explicitly vendor-extensible, so an unmodelled device is an expected + arrival rather than a hypothetical one. +- **Extension properties** (`SpanPanelSnapshot.extension_properties`). A vendor property on a device this parser _does_ model, carried with its value and the snapshot subject it hangs off. + +Devices are sorted by declared device type, never by device id. Field metadata comes from each device's own `$description` rather than from a schema document, because the same capability exposes different properties on different device classes — `meter` is +voltage on the panel, power and energy on a circuit, and both currents on the lugs. + +`ControllerRoutes` is how the eBus SDK reaches the panel without opening its own connection: it is an `ebus_sdk.MqttControllerTransport` that records `Controller`'s subscriptions instead of making them, so a single wildcard subscription owned by +span-panel-api's transport covers the whole tree and each message is routed to whichever SDK callback asked for it. + +## Conformance + +`spec_lock.json` ships with the package and records what this parser targets: the firmware range, the eBus specification commit its vocabulary was read from, and the version of every capability, device and registry it implements. The capability catalogs it +addresses are byte-copied under `spec/`. + +Those copies exist to be **checked against, never parsed in production** — units and datatypes come from each device's `$description`, since a catalog is the superset across all hardware rather than a statement about the panel in front of you. The suite +asks the consumer's question rather than the publisher's: is every name this adapter _reads_ one the specification defines? A consumer addressing a name that no longer exists does not fail loudly, it goes quiet — the property never arrives, metadata lookup +returns `None`, and an entity disappears. ## Reference payloads @@ -19,3 +60,5 @@ devices = devices_from_tree(parent_child_tree()) It ships here rather than from the bootstrap because a retained topic tree is only interpretable by the parser that speaks its vocabulary, and the eBus SDK is this distribution's dependency alone. `devices_from_tree` takes the tree rather than reading it, so a consumer can filter the capture first — dropping the BESS to model a panel that has none — and still build devices the same way. The bootstrap ships the schema document it fetches; see `span_panel_api.reference_payloads`. + +Each payload carries the version of the release it shipped in. Pin a version and you read the bytes that version was written against. diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index 42b35e1..45bcd14 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -1,22 +1,23 @@ [project] name = "span-panel-api-schema-1" -version = "0.1.0b10" +version = "1.0.0" description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api" authors = [ {name = "SpanPanel"} ] readme = "README.md" license = "MIT" -requires-python = ">=3.10,<4.0" +requires-python = ">=3.14,<4.0" dependencies = [ - # b12, not b4: this parser imports `ExtensionProperty` and `ExtensionSubject` - # and constructs `SpanPanelSnapshot(extension_properties=...)`, none of which - # 3.0.0b11 defines. A lower floor lets a resolver pair this wheel with a - # bootstrap that fails at import -- the precise hazard RELEASE.md warns about - # under "Releasing every distribution", and the same reason the floor moved to - # b3 for `SpanMidSnapshot` before it. schema-0 keeps its own lower floor; - # every name it imports is present there, checked rather than assumed. - "span-panel-api>=3.0.0b12,<4.0", + # 3.0.0 is the first bootstrap that defines everything this parser imports -- + # `SpanMidSnapshot`, `ExtensionProperty`, `ExtensionSubject`, and a + # `SpanPanelSnapshot` that accepts `extension_properties`. A lower floor lets + # a resolver pair this wheel with a bootstrap that fails at import, the + # precise hazard RELEASE.md warns about under "Releasing every distribution". + # Stated as a stable version rather than as the prerelease the floor tracked + # during development: naming a prerelease in a specifier is pip's own signal + # that prereleases are acceptable for that requirement. + "span-panel-api>=3.0.0,<4.0", # Only this distribution depends on the eBus SDK. The bootstrap and # schema-0 stay clean, so a flat-panel install never pulls it in — which is # what bounds the release coupling this dependency introduces to panels on diff --git a/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/__init__.py b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/__init__.py index 9502e51..05c360a 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/__init__.py +++ b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/__init__.py @@ -20,11 +20,10 @@ from collections.abc import Mapping from importlib import resources import json -from typing import TypeAlias from ebus_sdk.homie import DiscoveredDevice -RetainedTopicTree: TypeAlias = Mapping[str, Mapping[str, str]] +type RetainedTopicTree = Mapping[str, Mapping[str, str]] """A retained-topic capture: device id -> topic -> payload, all strings. `$description` is a JSON *string*, not a nested object — it is stored on the diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index 9b56e07..6086337 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -153,8 +153,8 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re evse_subjects = [ (device, ExtensionSubject(kind="evse", instance_key=key)) for device, key in harmonised_evse_keys(roles.evse).items() ] - # **Lugs are their own subject, keyed by direction.** They were `panel` in - # 0.1.0b9, which made the subject non-unique: a consumer keys an identity on + # **Lugs are their own subject, keyed by direction.** Pairing both with the + # `panel` subject makes the subject non-unique: a consumer keys an identity on # `(kind, instance_key, node/property)`, and the two lugs devices run the # same firmware, so a vendor extension on one is the *expected* case of a # vendor extension on both -- two wire addresses collapsing onto one diff --git a/pyproject.toml b/pyproject.toml index c0e7595..99eb581 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "3.0.0b13" +version = "3.0.0" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} @@ -8,14 +8,16 @@ authors = [ readme = "README.md" license = "MIT" license-files = ["LICENSE"] -requires-python = ">=3.10,<4.0" +requires-python = ">=3.14,<4.0" dependencies = [ # Bounded, and the bound is load-bearing. httpx 1.0 is an API rewrite that - # removes `AsyncClient` -- 1.0.dev1..dev4 are on PyPI now, and every - # distribution of this library is a prerelease, so `pip install --pre`, the - # verb RELEASE.md itself prescribes, resolves them. `paho-mqtt` has been - # bounded from the start; this was the one unbounded runtime dependency, and - # a ceiling cannot be added to a version already published. + # removes `AsyncClient`, and 1.0.dev1..dev4 are on PyPI now. The bound was + # first set when every distribution here was a prerelease and `pip install + # --pre` was the prescribed verb, which made those dev releases resolvable; + # that particular exposure ended at 3.0.0, but the ceiling stays, because a + # caller may still pass `--pre` for its own reasons and because a ceiling + # cannot be added to a version already published. `paho-mqtt` has been + # bounded from the start; this was the one unbounded runtime dependency. "httpx>=0.28.1,<1.0", "paho-mqtt>=2.0.0,<3.0.0", "pyyaml>=6.0.0", @@ -24,15 +26,20 @@ dependencies = [ [project.optional-dependencies] # Not runtime dependencies: this distribution still registers no adapter and # imports none, and `scripts/verify_adapterless_install.py` holds that line. -# These exist so `pip install -U --pre "span-panel-api[schema-0,schema-1]"` has a +# These exist so `pip install -U "span-panel-api[schema-0,schema-1]"` has a # correct upgrade path, because the dependency arrow runs the other way -- an # adapter floors on the bootstrap, the bootstrap requires no adapter -- so # upgrading the bootstrap alone leaves stale adapter wheels that # `_derive_required_members` then rejects at discovery, with pip reporting # success. An extra is the only thing pip can act on, and extras cannot be added # to a version after it is published. -schema-0 = ["span-panel-api-schema-0>=1.0.0b5"] -schema-1 = ["span-panel-api-schema-1>=0.1.0b6"] +# +# Floors are stable versions deliberately. A specifier that names a prerelease +# is pip's own signal that prereleases are acceptable for that requirement, so a +# `>=1.0.0b5` floor here would leave a released install willing to resolve a +# future beta of the adapter without anyone asking for one. +schema-0 = ["span-panel-api-schema-0>=1.0.0"] +schema-1 = ["span-panel-api-schema-1>=1.0.0"] [project.urls] Homepage = "https://github.com/SpanPanel/span-panel-api" @@ -159,13 +166,19 @@ ignore = [ force-sort-within-sections = true combine-as-imports = true split-on-trailing-comma = false -# Both distributions in this workspace are first-party. Stated explicitly -# because the repo now has two source roots, and inference from a single `src/` -# would classify the adapter package as third-party. -known-first-party = ["span_panel_api", "span_panel_api_schema_0"] +# Every distribution in this workspace is first-party. Stated explicitly because +# the repo has more than one source root, and inference from a single `src/` +# would classify the adapter packages as third-party. Each new adapter package +# has to be added here -- schema_1 was missing for several releases, which is +# the failure this comment exists to prevent and did not. +known-first-party = ["span_panel_api", "span_panel_api_schema_0", "span_panel_api_schema_1"] [tool.mypy] -python_version = "3.13" +# The declared floor. Type-checking as of the oldest supported interpreter is +# what catches a call into stdlib that only exists further up; checking as of a +# newer one would let it through. Floor and ceiling are the same version today, +# so this must move with `requires-python` rather than being left behind. +python_version = "3.14" strict = true warn_return_any = true warn_unused_configs = true diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 52f2c8c..3c1f338 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -10,11 +10,10 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import TypeAlias # Homie schema type: {type_name: {property_name: {attribute: value}}} # Values are heterogeneous JSON (str, int, bool, nested dicts). -HomieSchemaTypes: TypeAlias = dict[str, dict[str, object]] +type HomieSchemaTypes = dict[str, dict[str, object]] @dataclass(frozen=True, slots=True) diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 4d2601d..0d17eb7 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -393,7 +393,7 @@ async def connect(self) -> None: # Wait for Homie ready state try: await asyncio.wait_for(self._ready_event.wait(), timeout=MQTT_READY_TIMEOUT_S) - except asyncio.TimeoutError as exc: + except TimeoutError as exc: await self.close() raise SpanPanelConnectionError(f"Timed out waiting for Homie device ready ({self._serial_number})") from exc diff --git a/src/span_panel_api/mqtt/connection.py b/src/span_panel_api/mqtt/connection.py index 212c81e..2f6ea72 100644 --- a/src/span_panel_api/mqtt/connection.py +++ b/src/span_panel_api/mqtt/connection.py @@ -218,7 +218,7 @@ def _blocking_connect() -> None: # Wait for CONNACK try: await asyncio.wait_for(self._connect_event.wait(), timeout=MQTT_CONNECT_TIMEOUT_S) - except asyncio.TimeoutError as exc: + except TimeoutError as exc: await self.disconnect() raise SpanPanelTimeoutError(f"Timed out connecting to MQTT broker at {self._host}:{self._port}") from exc diff --git a/tests/test_adapters_discovery.py b/tests/test_adapters_discovery.py index 5d5a04f..8138b6d 100644 --- a/tests/test_adapters_discovery.py +++ b/tests/test_adapters_discovery.py @@ -345,8 +345,8 @@ def test_a_contract_that_is_not_an_integer_is_rejected() -> None: def test_an_adapter_predating_contract_versioning_is_rejected_by_age_not_by_shape() -> None: - """The real regression this closes: schema-1 0.1.0b1 paired with a bootstrap - whose adapters took `panel_size`. Such an adapter carries every other + """The real regression this closes: an early schema-1 build paired with a + bootstrap whose adapters took `panel_size`. Such an adapter carries every other required name, so nothing but the contract member distinguishes it, and without one it reached construction and died on argument count.""" members = _conforming_members() diff --git a/tests/test_schema_one_against_simulator.py b/tests/test_schema_one_against_simulator.py index 1881a14..ea85ced 100644 --- a/tests/test_schema_one_against_simulator.py +++ b/tests/test_schema_one_against_simulator.py @@ -48,8 +48,8 @@ def _adapter() -> SchemaOneAdapter: """Feed the capture the way the broker replays it. Sorted by topic rather than tree order, on purpose: the retained store has no - notion of parents before children, and the ordering bug fixed before 0.1.0b1 - was exactly a case of that assumption being made silently. + notion of parents before children, and the ordering bug fixed before the + first release was exactly a case of that assumption being made silently. """ with _WIRE.open() as handle: capture: dict[str, dict[str, str]] = json.load(handle) diff --git a/uv.lock b/uv.lock index 9e254c7..c960c95 100644 --- a/uv.lock +++ b/uv.lock @@ -1,11 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.10, <4.0" -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", -] +requires-python = ">=3.14, <4.0" [manifest] members = [ @@ -19,9 +14,7 @@ name = "anyio" version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ @@ -32,32 +25,11 @@ wheels = [ name = "astroid" version = "4.0.4" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" }, ] -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, -] - -[[package]] -name = "backports-tarfile" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, -] - [[package]] name = "bandit" version = "1.9.4" @@ -84,31 +56,9 @@ dependencies = [ { name = "pathspec" }, { name = "platformdirs" }, { name = "pytokens" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/a8/11170031095655d36ebc6664fe0897866f6023892396900eec0e8fdc4299/black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2", size = 1866562, upload-time = "2026-03-12T03:39:58.639Z" }, - { url = "https://files.pythonhosted.org/packages/69/ce/9e7548d719c3248c6c2abfd555d11169457cbd584d98d179111338423790/black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b", size = 1703623, upload-time = "2026-03-12T03:40:00.347Z" }, - { url = "https://files.pythonhosted.org/packages/7f/0a/8d17d1a9c06f88d3d030d0b1d4373c1551146e252afe4547ed601c0e697f/black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac", size = 1768388, upload-time = "2026-03-12T03:40:01.765Z" }, - { url = "https://files.pythonhosted.org/packages/52/79/c1ee726e221c863cde5164f925bacf183dfdf0397d4e3f94889439b947b4/black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a", size = 1412969, upload-time = "2026-03-12T03:40:03.252Z" }, - { url = "https://files.pythonhosted.org/packages/73/a5/15c01d613f5756f68ed8f6d4ec0a1e24b82b18889fa71affd3d1f7fad058/black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a", size = 1220345, upload-time = "2026-03-12T03:40:04.892Z" }, - { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987, upload-time = "2026-03-12T03:40:06.248Z" }, - { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499, upload-time = "2026-03-12T03:40:07.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369, upload-time = "2026-03-12T03:40:09.279Z" }, - { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613, upload-time = "2026-03-12T03:40:10.943Z" }, - { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719, upload-time = "2026-03-12T03:40:12.597Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, - { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, - { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, - { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, - { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, - { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, - { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, - { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, @@ -135,28 +85,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, @@ -182,70 +110,6 @@ version = "3.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" }, - { url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" }, - { url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" }, - { url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" }, - { url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" }, - { url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" }, - { url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" }, - { url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" }, - { url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" }, - { url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" }, - { url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" }, - { url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" }, - { url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" }, - { url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" }, - { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, - { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, - { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, - { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, - { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, - { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, - { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, - { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, - { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, - { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, - { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, - { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, - { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, - { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, - { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, - { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, - { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, - { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, - { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, - { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, - { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, - { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, - { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, - { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, - { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, - { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, - { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, - { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, - { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, - { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, - { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, - { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, - { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, - { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, - { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, - { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, @@ -308,80 +172,6 @@ version = "7.13.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, - { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, - { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, - { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, - { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, - { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, - { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, - { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, - { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, - { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, - { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, - { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, - { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, - { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, - { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, - { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, - { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, - { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, - { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, - { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, - { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, - { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, - { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, - { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, - { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, - { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, - { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, - { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, - { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, - { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, - { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, - { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, - { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, - { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, - { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, - { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, - { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, - { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, - { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, - { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, - { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, - { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, - { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, @@ -415,18 +205,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, -] - [[package]] name = "cryptography" version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ @@ -457,10 +241,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, - { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, - { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, - { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, ] [[package]] @@ -492,38 +272,26 @@ wheels = [ [[package]] name = "ebus-mqtt-client" -version = "0.4.0" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "paho-mqtt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/05/43c255aac2fe76e51642080315897306751fee1d4414fc6a099a1a5d9af5/ebus_mqtt_client-0.4.0.tar.gz", hash = "sha256:83ac9cfe4672fbbc1622d46ad7fe53d345654ca0dd85109c0894cb9fea8c73b1", size = 27690, upload-time = "2026-08-03T19:58:51.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/63/4eb799040e1accb243da6ec2726baff2fb8eed5c5aaab0d5e88f98816820/ebus_mqtt_client-0.5.0.tar.gz", hash = "sha256:4cc823b7011dfa8e90ad1606fec971fa4e7dae505e6e534c936a2097883b4e63", size = 36852, upload-time = "2026-08-22T04:45:27.488Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/f3/e5549b9d340c958bf9ee8927b23ce724b7296a76b395ff0f5cbe55be1f77/ebus_mqtt_client-0.4.0-py3-none-any.whl", hash = "sha256:d64d6ac7f39f42791a59c932ce1cebefadb35accf88b1b3367258fa5fb7f54ff", size = 16625, upload-time = "2026-08-03T19:58:50.606Z" }, + { url = "https://files.pythonhosted.org/packages/80/62/d20526aa3f4c9ebeadf127ff73a6bc608dfe4f310d947ac4f910af1eea36/ebus_mqtt_client-0.5.0-py3-none-any.whl", hash = "sha256:cb9b6599b39c0e28e05283b6d811017ff53c30d31b4eaf35270648fba71a6a77", size = 20225, upload-time = "2026-08-22T04:45:26.31Z" }, ] [[package]] name = "ebus-sdk" -version = "0.21.0" +version = "0.23.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ebus-mqtt-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/3c/784455cbe0e815359b32e2cd6807a889ef53e1bf84ed4210d668c2ee9dfb/ebus_sdk-0.21.0.tar.gz", hash = "sha256:3e6341cfcce9a4d9d37077b0e2edf4392cbe7ecace56596762da0b43914e4ea8", size = 194259, upload-time = "2026-08-20T15:38:15.654Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/de/b50c928bb5639fea939ed7b1dd4bb8e9300e852514a82babcb9bccce6b17/ebus_sdk-0.23.1.tar.gz", hash = "sha256:1ac444c018c011319da29084def7002b87a733b0083dc7d5ee78aa72d71f8312", size = 205970, upload-time = "2026-08-21T15:04:36.568Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/91/42bed9e8b9f1f33adbf4a4e5768327166b8ab2cfc8b8f3af10bf93984925/ebus_sdk-0.21.0-py3-none-any.whl", hash = "sha256:ba0b8f1398e827defbad33f355260accc4ac52917044e96b7b1865fffed4a65a", size = 111554, upload-time = "2026-08-20T15:38:14.046Z" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/e59d94cdd3ed926ab7339cc74fb6f026eccc2d6c94811b4e4671e199329e/ebus_sdk-0.23.1-py3-none-any.whl", hash = "sha256:7d99e136cffe81cbffe13240e4791b38ea0c52c21c818de243c31dec00aa6047", size = 117308, upload-time = "2026-08-21T15:04:35.343Z" }, ] [[package]] @@ -602,18 +370,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] -[[package]] -name = "importlib-metadata" -version = "9.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" @@ -648,9 +404,6 @@ wheels = [ name = "jaraco-context" version = "6.1.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/27/7b/c3081ff1af947915503121c649f26a778e1a2101fd525f74aef997d75b7e/jaraco_context-6.1.1.tar.gz", hash = "sha256:bc046b2dc94f1e5532bd02402684414575cc11f565d929b6563125deb0a6e581", size = 15832, upload-time = "2026-03-07T15:46:04.63Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/49/c152890d49102b280ecf86ba5f80a8c111c3a155dafa3bd24aeb64fde9e1/jaraco_context-6.1.1-py3-none-any.whl", hash = "sha256:0df6a0287258f3e364072c3e40d5411b20cafa30cb28c4839d24319cecf9f808", size = 7005, upload-time = "2026-03-07T15:46:03.515Z" }, @@ -682,7 +435,6 @@ name = "keyring" version = "25.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, { name = "jaraco-classes" }, { name = "jaraco-context" }, { name = "jaraco-functools" }, @@ -701,57 +453,6 @@ version = "0.8.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, - { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, - { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, - { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, - { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, - { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, - { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, - { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, - { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, - { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, - { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, - { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, - { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, - { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, - { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, - { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, - { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, - { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, - { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, - { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, - { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, - { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, - { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, - { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, - { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, - { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, - { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, - { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, - { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, - { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, @@ -839,35 +540,10 @@ dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, - { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, - { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, - { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, - { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, - { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, - { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, - { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, - { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, - { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, - { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, @@ -1019,7 +695,6 @@ dependencies = [ { name = "isort" }, { name = "mccabe" }, { name = "platformdirs" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "tomlkit" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" } @@ -1033,12 +708,10 @@ version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ @@ -1050,9 +723,7 @@ name = "pytest-asyncio" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ @@ -1064,7 +735,7 @@ name = "pytest-cov" version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", extra = ["toml"] }, + { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] @@ -1092,26 +763,6 @@ version = "0.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, - { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, - { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, - { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, - { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, - { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, - { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, - { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, - { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, - { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, - { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, - { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, @@ -1140,44 +791,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, @@ -1323,7 +936,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "3.0.0b13" +version = "3.0.0" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -1391,7 +1004,7 @@ dev = [ [[package]] name = "span-panel-api-schema-0" -version = "1.0.0b5" +version = "1.0.0" source = { editable = "packages/schema-0" } dependencies = [ { name = "span-panel-api" }, @@ -1402,7 +1015,7 @@ requires-dist = [{ name = "span-panel-api", editable = "." }] [[package]] name = "span-panel-api-schema-1" -version = "0.1.0b10" +version = "1.0.0" source = { editable = "packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, @@ -1424,60 +1037,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/06/36d260a695f383345ab5bbc3fd447249594ae2fa8dfd19c533d5ae23f46b/stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed", size = 54483, upload-time = "2026-02-20T13:27:05.561Z" }, ] -[[package]] -name = "tomli" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, -] - [[package]] name = "tomlkit" version = "0.14.0" @@ -1543,7 +1102,6 @@ dependencies = [ { name = "filelock" }, { name = "platformdirs" }, { name = "python-discovery" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } wheels = [ @@ -1554,19 +1112,7 @@ wheels = [ name = "vulture" version = "2.15" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tomli", marker = "python_full_version < '3.11'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/59/c6/4f147b621b4c0899eb1770f98113334bb706ebd251ac2be979316b1985fa/vulture-2.15.tar.gz", hash = "sha256:f9d8b4ce29c69950d323f21dceab4a4d6c694403dffbed7713c4691057e561fe", size = 52438, upload-time = "2026-03-04T21:41:39.096Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1c/f3/07cf122e145bc6df976030e9935123124c3fcb5044cf407b5e71e85821b4/vulture-2.15-py3-none-any.whl", hash = "sha256:a3d8ebef918694326620eb128fa783486c8d285b23381c2b457d864ac056ef8d", size = 26895, upload-time = "2026-03-04T21:41:39.878Z" }, ] - -[[package]] -name = "zipp" -version = "3.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, -]