From d8c28dde860ad238148b018b0739da130a20f881 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:35:12 -0700 Subject: [PATCH 1/8] test(fidelity): replay the public MAIN 32 captures through the adapter and check every property Each capture is replayed through SchemaOneAdapter and every declared property is perturbed to see which snapshot leaves it moves. The harness fails on a valued property that reaches nothing without code that says why, on a value with no valued wire property behind it, and on a mapped value that breaks the transform documented for its field. The rules cite the library code behind every transform, derivation and intentional drop, and cover every row of _PROPERTY_FIELD_MAP and the lugs tables. Two cells: the MAIN 32 capture on spanos3/r202639/03 and the MAIN 32 capture on spanos3/r202633/02, vendored byte-identical from the eBus panel simulator's v0.9.0 tag with their SHA-256 pinned. The r202633 capture's upstream-lugs link status is recorded as a deferral with its reason. The check that no device is shown by its own id while it forms the grid is a strict xfail until the fix later in this branch. The harness is type-checked by the mypy hook like the other test support. --- .pre-commit-config.yaml | 5 +- pyproject.toml | 6 +- tests/adapter_fidelity/__init__.py | 5 + tests/adapter_fidelity/harness.py | 815 ++++ tests/adapter_fidelity/rules.py | 354 ++ tests/adapter_fidelity/test_harness.py | 142 + .../adapter_fidelity/test_main32_fidelity.py | 137 + tests/fixtures/main32-tree-v1.README.md | 6 + tests/fixtures/main32-tree-v1.json | 3748 +++++++++++++++++ .../fixtures/main32_r202639-tree-v1.README.md | 6 + tests/fixtures/main32_r202639-tree-v1.json | 3292 +++++++++++++++ 11 files changed, 8514 insertions(+), 2 deletions(-) create mode 100644 tests/adapter_fidelity/__init__.py create mode 100644 tests/adapter_fidelity/harness.py create mode 100644 tests/adapter_fidelity/rules.py create mode 100644 tests/adapter_fidelity/test_harness.py create mode 100644 tests/adapter_fidelity/test_main32_fidelity.py create mode 100644 tests/fixtures/main32-tree-v1.README.md create mode 100644 tests/fixtures/main32-tree-v1.json create mode 100644 tests/fixtures/main32_r202639-tree-v1.README.md create mode 100644 tests/fixtures/main32_r202639-tree-v1.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a4046ad..7c7f8de 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -103,11 +103,14 @@ repos: # names tests/, which is fine — that flag governs directory discovery, and # a file passed by name is checked regardless. # + # `tests/adapter_fidelity/` is checked for the same reason: its harness and + # rules are the support code every adapter fidelity cell is measured with. + # # `scripts/capture_parent_child_reference.py` is checked for the same reason: # every schema_1 test is written against what it writes, which is not the place # for the one part of the tree nothing type-checks. The hand-run scripts beside # it stay out — see `pyproject.toml`, which names them one by one. - exclude: '^src/span_panel_api/generated_client/.*|scripts/(?!capture_parent_child_reference\.py$).*|tests/(?!reference_payloads/).*|docs/.*|examples/.*|\..*_cache/.*|dist/.*|venv/.*' + exclude: '^src/span_panel_api/generated_client/.*|scripts/(?!capture_parent_child_reference\.py$).*|tests/(?!reference_payloads/|adapter_fidelity/).*|docs/.*|examples/.*|\..*_cache/.*|dist/.*|venv/.*' # Pylint for code quality - repo: https://github.com/pycqa/pylint diff --git a/pyproject.toml b/pyproject.toml index 9ceb95c..e129dcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -245,7 +245,11 @@ warn_return_any = true warn_unused_configs = true disallow_untyped_defs = true explicit_package_bases = true -mypy_path = "src" +# `tests` because the suite imports its support packages top-level +# (`from reference_payloads.schema_one import ...`), the way pytest's rootdir puts +# `tests/` on `sys.path`; the support packages the mypy hook checks resolve the same +# way here. +mypy_path = "src:tests" # Excluded by name for the reason `[tool.ruff]` gives above: the capture script is # checked like the library, and the hand-run tools written before that was expected # are not. diff --git a/tests/adapter_fidelity/__init__.py b/tests/adapter_fidelity/__init__.py new file mode 100644 index 0000000..fd10228 --- /dev/null +++ b/tests/adapter_fidelity/__init__.py @@ -0,0 +1,5 @@ +"""Adapter fidelity: captured device trees replayed through the schema-1 adapter. + +`harness` measures one capture and names no panel, model or capture; `rules` +documents why each measured outcome is right; the test modules pick captures. +""" diff --git a/tests/adapter_fidelity/harness.py b/tests/adapter_fidelity/harness.py new file mode 100644 index 0000000..2bf9c7d --- /dev/null +++ b/tests/adapter_fidelity/harness.py @@ -0,0 +1,815 @@ +"""Replay a captured device tree through the schema-1 adapter and account for every property. + +The question is whether `SchemaOneAdapter` turns what a panel publishes into the +snapshot faithfully: every declared property reaches the snapshot or is dropped by +code that says why, every value it carries is the wire value under the field's +documented transform, and no value appears that nothing on the wire produced. + +**Method.** + +1. *Replay.* A ``tree-v1`` capture (``{"devices": {id: {"description", + "properties", "numeric_properties"}}}``) is fed to the real adapter as retained + messages, the path `SpanMqttClient.get_snapshot` takes: the root first, then + breadth-first by declared children, each device's ``$description``, then + ``$state ready``, then its values. +2. *Flatten.* The snapshot, the command targets and the label wait are flattened to + ``leaf path -> value``. +3. *Perturb.* Every declared property, valued or not, is published with values + that differ from the captured one, chosen from its declared datatype, and the + leaves that move are its effect. A valued property is perturbed on one session + and restored by republishing its captured value, as a broker update would; an + unvalued one is perturbed on a replay of its own, since nothing on the wire + returns a property to unvalued. The session must end equal to the baseline. +4. *Classify* each declared property instance by its effect into a `Fate`. +5. *Check values.* Every leaf a valued property moves must equal the wire value + under the transform `CellRules` documents for it. +6. *Audit leaves.* Every non-empty leaf that no valued property moves must be + a derivation `CellRules` documents; anything else is fabricated. + +Shape-generic: nothing here names a panel model or a capture. What is right for a +given cell lives in its `CellRules`. +""" + +from __future__ import annotations + +from collections import Counter +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field, fields, is_dataclass +from enum import StrEnum +import json +import math +from pathlib import Path +import re +from typing import Final + +from reference_payloads.schema_one import RetainedTopicTree, devices_from_tree +from span_panel_api.models import ( + ADOPTION_IDENTITY_NODE, + ADOPTION_TOPOLOGY_NODE, + AdoptedDevice, + ControlTarget, + ExtensionProperty, + V2HomieSchema, +) +from span_panel_api_schema_1.adapter import SchemaOneAdapter +from span_panel_api_schema_1.const import ( + DEVICE_TYPE_PREFIX, + HOMIE_DOMAIN, + HOMIE_VERSION, + NODE_GRID, + PROP_GRID_FORMING_ENTITY, + TYPE_BESS, + TYPE_CIRCUIT, + TYPE_LUGS, + TYPE_MID, +) +from span_panel_api_schema_1.field_metadata import _CONSUMED_OFF_SNAPSHOT, addressed_rows, is_addressed +from span_panel_api_schema_1.firmware import release_build + +ANY_LEAF: Final = "*" +"""A `CellRules.transforms` family that stands for every leaf a property moves.""" + + +class Fate(StrEnum): + """What became of one declared property.""" + + MAPPED = "mapped" + EXTENSION = "extension" + ADOPTED = "adopted" + CONTROL = "control/readiness only" + INTENTIONAL = "intentionally dropped" + LATENT = "latent" + """Declared and never valued here; the library addresses it, so it would be read.""" + INERT = "inert" + """Declared and never valued here; nothing reads it.""" + SILENT = "silently dropped" + """Valued, with no effect anywhere, and no code that says why.""" + + +_REACHES: Final = (Fate.MAPPED, Fate.EXTENSION, Fate.ADOPTED, Fate.CONTROL) +_FATE_ORDER: Final = {fate: index for index, fate in enumerate(Fate)} + + +@dataclass(frozen=True, slots=True, order=True) +class RowKey: + """One row of the coverage matrix: a property on a device role.""" + + role: str # device role, e.g. "circuit", "bess", "panel", "lugs-upstream" + node_property: str # e.g. "meter/active-power" + + +class TransformKind(StrEnum): + """How a snapshot leaf follows from the wire value that moves it.""" + + IDENTITY = "identity" # the number the wire carries + NEGATE = "negate" # its negation, with no -0.0 + INT = "int" # the number as an int + LITERAL = "literal" # the string exactly as published + BOOL = "bool" # True exactly when the wire says "true" + DERIVED = "derived" # a documented derivation, not a one-to-one transform + + +@dataclass(frozen=True, slots=True) +class Transform: + """The documented transform from one wire property to one snapshot leaf family.""" + + kind: TransformKind + cite: str + earlier: tuple[int, TransformKind] | None = None + """``(build, kind)``: before release build ``build`` the wire carries ``kind`` instead.""" + + def kind_for(self, firmware: str) -> TransformKind: + """The transform for a panel on ``firmware``. + + Where the transform changed at a release build and the firmware string + names none, the library decides by comparing signs instead, so the value + is then a derivation rather than a fixed transform. + """ + if self.earlier is None: + return self.kind + build = release_build(firmware) + if build is None: + return TransformKind.DERIVED + first_build, before = self.earlier + return self.kind if build >= first_build else before + + +@dataclass(frozen=True, slots=True) +class Derivation: + """A leaf that may hold a value no valued wire property moves on its own.""" + + family: str + cite: str + values: tuple[str | bool, ...] | None = None + """The values the derivation can produce; ``None`` for any.""" + inputs: tuple[str, ...] = () + """``node/property`` on the leaf's own device that must all be valued. + + For a value two valued properties decide together, where perturbing either + one alone cannot move it. + """ + + +class DropReason(StrEnum): + """Why the library reads a declared property into nothing, by design.""" + + OFF_SNAPSHOT = "read by a route no snapshot field shows" + IDENTITY_OR_TOPOLOGY = "an identity or topology property, never a reading" + EXTRA_INSTANCE = "a further instance of a device the snapshot holds one of" + + +@dataclass(frozen=True, slots=True) +class CellRules: + """What is right for a cell, each entry citing the code that makes it so.""" + + transforms: Mapping[RowKey, Mapping[str, Transform]] + """Row -> leaf family (or `ANY_LEAF`) -> the transform from the wire value.""" + derivations: tuple[Derivation, ...] + drops: Mapping[DropReason, str] + """The code behind each kind of intentional drop.""" + deferred: Mapping[RowKey, str] + """Valued rows the library drops today, each with the reason it is deferred.""" + + @property + def justifications(self) -> dict[str, str]: + """Every citation and reason, keyed by what it justifies.""" + cited: dict[str, str] = {} + for key, by_family in self.transforms.items(): + for family_name, transform in by_family.items(): + cited[f"transform {key.role} {key.node_property} -> {family_name}"] = transform.cite + for derivation in self.derivations: + cited[f"derivation {derivation.family}"] = derivation.cite + for reason, cite in self.drops.items(): + cited[f"drop: {reason}"] = cite + for key, reason_text in self.deferred.items(): + cited[f"deferred {key.role} {key.node_property}"] = reason_text + return cited + + def transform_for(self, key: RowKey, family_name: str) -> Transform | None: + by_family = self.transforms.get(key, {}) + return by_family.get(family_name) or by_family.get(ANY_LEAF) + + def derivation_for(self, family_name: str, value: object, device_values: Mapping[str, str] | None) -> Derivation | None: + for derivation in self.derivations: + if derivation.family != family_name: + continue + if derivation.values is not None and not any(_same(value, allowed) for allowed in derivation.values): + continue + if derivation.inputs and ( + device_values is None or any(device_values.get(prop) is None for prop in derivation.inputs) + ): + continue + return derivation + return None + + +@dataclass(frozen=True, slots=True) +class FidelityReport: + """One capture measured against its `CellRules`.""" + + rows: Mapping[RowKey, tuple[Fate, ...]] + silently_dropped: tuple[RowKey, ...] + deferred: tuple[RowKey, ...] + """Rows that would be silently dropped, held back by a reason in `CellRules.deferred`.""" + fabricated: tuple[str, ...] # snapshot leaf paths + mismatched: tuple[str, ...] # snapshot leaf paths whose value breaks the transform + declared_property_instances: int + instance_fates: Mapping[Fate, int] + leaf_rows: Mapping[str, frozenset[RowKey]] + """Each fabricated or mismatched leaf -> the rows that own it.""" + notes: Mapping[str, str] + """Each fabricated or mismatched leaf -> what is wrong with it.""" + derivations_used: frozenset[str] + """Families of the `CellRules.derivations` that accounted for a leaf here.""" + grid_forming_names: Mapping[str, str | None] + """Device id -> the name the snapshot shows while the MID names that device as forming the grid.""" + + def failing_rows(self) -> frozenset[RowKey]: + """The silently dropped rows and the rows owning a fabricated or mismatched leaf.""" + owned = (key for leaf in (*self.fabricated, *self.mismatched) for key in self.leaf_rows.get(leaf, frozenset())) + return frozenset((*self.silently_dropped, *owned)) + + def unowned_leaves(self) -> frozenset[str]: + """Fabricated or mismatched leaves no wire row owns (a synthesised entry, a constant field). + + `failing_rows()` cannot see them, so a gate compares them separately. + """ + return frozenset(leaf for leaf in (*self.fabricated, *self.mismatched) if not self.leaf_rows.get(leaf)) + + +# --- the capture ----------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class _Device: + device_id: str + device_type: str + description: Mapping[str, object] + values: Mapping[str, str] # "node/property" -> raw wire value + declared: Mapping[str, Mapping[str, object]] # "node/property" -> declaration + children: tuple[str, ...] + parent: str + + +@dataclass(frozen=True, slots=True) +class _Capture: + devices: Mapping[str, _Device] + root_id: str + order: tuple[str, ...] + """The root, then breadth-first by declared children, then any device no parent names.""" + + @property + def firmware(self) -> str: + return self.devices[self.root_id].values.get("info/firmware-version", "") + + def retained(self) -> RetainedTopicTree: + return { + device_id: {"$description": json.dumps(device.description), "$state": "ready", **device.values} + for device_id, device in self.devices.items() + } + + +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 _load(tree_path: Path) -> _Capture: + tree = _mapping(json.loads(tree_path.read_text(encoding="utf-8"))) + devices: dict[str, _Device] = {} + for device_id, entry in _mapping(tree.get("devices")).items(): + record = _mapping(entry) + description = _mapping(record.get("description")) + values: dict[str, str] = {} + for bucket in ("properties", "numeric_properties"): + for topic, raw in _mapping(record.get(bucket)).items(): + if not isinstance(raw, str): + raise TypeError(f"{tree_path.name}: {device_id}/{topic} is not a wire string: {raw!r}") + values[topic] = raw + declared = { + f"{node_id}/{property_id}": _mapping(declaration) + for node_id, node in _mapping(description.get("nodes")).items() + for property_id, declaration in _mapping(_mapping(node).get("properties")).items() + } + children = description.get("children") + devices[device_id] = _Device( + device_id=device_id, + device_type=_text(description.get("type")), + description=description, + values=values, + declared=declared, + children=tuple(_text(child) for child in children) if isinstance(children, list) else (), + parent=_text(description.get("parent")), + ) + roots = [device_id for device_id, device in devices.items() if not device.parent] + if len(roots) != 1: + raise ValueError(f"{tree_path.name}: expected one root device, found {roots}") + order: list[str] = [] + queue = [roots[0]] + while queue: + device_id = queue.pop(0) + if device_id in order or device_id not in devices: + continue + order.append(device_id) + queue.extend(devices[device_id].children) + order.extend(sorted(device_id for device_id in devices if device_id not in order)) + return _Capture(devices=devices, root_id=roots[0], order=tuple(order)) + + +# --- replay and flatten ---------------------------------------------------- + + +def _frozen_clock() -> float: + """The feed-grace clock, held still so every replay sees the same label wait.""" + return 1000.0 + + +def _replay(capture: _Capture) -> SchemaOneAdapter: + """A fresh adapter fed the capture as retained messages.""" + schema = V2HomieSchema( + firmware_version=capture.firmware, + types_schema_hash="sha256:capture", + types={}, + data_model_version="1.0", + ) + adapter = SchemaOneAdapter(capture.root_id, schema, clock=_frozen_clock) + for device_id in capture.order: + device = capture.devices[device_id] + prefix = f"{HOMIE_DOMAIN}/{HOMIE_VERSION}/{device_id}" + adapter.handle_message(f"{prefix}/$description", json.dumps(device.description)) + adapter.handle_message(f"{prefix}/$state", "ready") + for topic in sorted(device.values): + adapter.handle_message(f"{prefix}/{topic}", device.values[topic]) + return adapter + + +_DEVICE_MAPS: Final = {"circuits": "circuit", "pv_inverters": "pv_inverter", "evse": "evse"} +"""Snapshot fields that map a key to one device's snapshot, and the leaf family each device takes.""" +_DEVICE_FIELDS: Final = frozenset({"battery", "pv", "mid", "pcs"}) +_INDEXED = re.compile(r"^(?P[a-z_]+)\[(?P[^\]]*)\]\.(?P.+)$") +_INSTANCE = re.compile(r"^[a-z_.]+\[([^\]]*)\]") + + +def _plain(value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + +def _record(leaves: dict[str, object], prefix: str, item: object) -> None: + if item is None: + leaves[prefix] = None + return + if not is_dataclass(item) or isinstance(item, type): + raise TypeError(f"{prefix} is not a device snapshot: {item!r}") + for item_field in fields(item): + leaves[f"{prefix}.{item_field.name}"] = _plain(getattr(item, item_field.name)) + + +def _topic(target: ControlTarget | None) -> str | None: + return None if target is None else target.topic + + +def _extension_leaves(leaves: dict[str, object], properties: Iterable[ExtensionProperty]) -> None: + for prop in properties: + key = f"ext[{prop.subject.kind}|{prop.subject.instance_key or ''}|{prop.node_id}/{prop.property_id}]" + leaves[key] = prop.value + + +def _adopted_leaves(leaves: dict[str, object], devices: Iterable[AdoptedDevice]) -> None: + for device in devices: + base = f"adopted[{device.device_id}]" + for device_field in fields(device): + if device_field.name != "properties": + leaves[f"{base}.{device_field.name}"] = _plain(getattr(device, device_field.name)) + for prop in device.properties: + leaves[f"{base}.prop[{prop.node_id}/{prop.property_id}]"] = prop.value + + +def _flatten(adapter: SchemaOneAdapter) -> dict[str, object]: + """``leaf path -> value`` for the snapshot, the command targets and the label wait.""" + leaves: dict[str, object] = { + "ready.is_ready": adapter.is_ready(), + "ready.missing_names": tuple(sorted(adapter.circuit_nodes_missing_names())), + } + if not adapter.is_ready(): + return leaves + snapshot = adapter.build_snapshot() + for snapshot_field in fields(snapshot): + name = snapshot_field.name + value: object = getattr(snapshot, name) + if name == "extension_properties": + _extension_leaves(leaves, snapshot.extension_properties) + elif name == "adopted_devices": + _adopted_leaves(leaves, snapshot.adopted_devices) + elif name in _DEVICE_MAPS: + if not isinstance(value, Mapping): + raise TypeError(f"snapshot field {name!r} is not a map") + for key, item in value.items(): + _record(leaves, f"{name}[{key}]", item) + elif name in _DEVICE_FIELDS: + _record(leaves, name, value) + elif is_dataclass(value) or isinstance(value, Mapping | list | tuple): + raise TypeError(f"snapshot field {name!r} holds a structure this harness does not flatten") + else: + leaves[f"panel.{name}"] = value + for circuit_id in snapshot.circuits: + leaves[f"ctl.relay_target[{circuit_id}]"] = _topic(adapter.set_circuit_relay_target(circuit_id)) + leaves[f"ctl.priority_target[{circuit_id}]"] = _topic(adapter.set_circuit_priority_target(circuit_id)) + for evse_key in snapshot.evse: + leaves[f"ctl.evse_limit_target[{evse_key}]"] = _topic(adapter.set_evse_charge_limit_target(evse_key)) + leaves["ctl.dps_target"] = _topic(adapter.set_dominant_power_source_target()) + return leaves + + +def family(leaf: str) -> str: + """A leaf path without its instance: ``circuits[abc].instant_power_w`` -> ``circuit.instant_power_w``.""" + if leaf.startswith("ext["): + kind, _key, path = leaf[len("ext[") : -1].split("|", 2) + return f"ext.{kind}:{path}" + if leaf.startswith("adopted["): + return "adopted" + re.sub(r"\[[^\]]*\]", "", leaf.split("]", 1)[1]) + if leaf.startswith("ctl."): + return re.sub(r"\[[^\]]*\]$", "", leaf) + match = _INDEXED.match(leaf) + if match is not None: + return f"{_DEVICE_MAPS[match['container']]}.{match['field']}" + return leaf + + +def _instance(leaf: str) -> str | None: + match = _INSTANCE.match(leaf) + return None if match is None else match.group(1) + + +# --- perturbation ---------------------------------------------------------- + + +def _number(raw: str) -> float | None: + try: + return float(raw) + except ValueError: + return None + + +def _candidates( + declaration: Mapping[str, object], raw: str | None, property_id: str, device_ids: Iterable[str] +) -> list[str]: + """Values that differ from ``raw`` and are legal for the declaration. + + Several per property, so that a derived field with a threshold (``poles >= 2``) + or a reference that must resolve (a ``*-device-id`` naming a device in the + tree) is exercised rather than reported as having no effect. Every member of an + enum, because a derived field may move for only one of them, and one value + outside it. + """ + datatype = _text(declaration.get("datatype")) or "string" + base = None if raw is None else _number(raw) + pool: list[str] + if datatype == "boolean": + pool = ["true", "false"] + elif datatype == "enum": + members = [member.strip() for member in _text(declaration.get("format")).split(",") if member.strip()] + pool = [*members, "PERTURBED_ENUM"] + elif datatype == "integer": + integers = [int(base) + 13] if base is not None else [] + pool = [str(value) for value in (*integers, 1, 2, 0, 13, -7)] + elif datatype == "float": + floats = [base + 13.25, -base] if base else [] + pool = [repr(float(value)) for value in (*floats, 0.0, 13.25, -7.5)] + elif datatype == "json": + pool = ['{"perturbed": true}', "{}"] + else: + pool = [f"{raw}~p" if raw else "perturbed-value"] + if property_id.endswith(("-device-id", "-entity")): + pool.extend((*device_ids, "GRID")) + distinct: list[str] = [] + for value in pool: + if value != raw and value not in distinct: + distinct.append(value) + return distinct[:3] if datatype in ("integer", "float") else distinct + + +_MISSING: Final = object() + + +def _same(left: object, right: object) -> bool: + if isinstance(left, float) and isinstance(right, float) and math.isnan(left) and math.isnan(right): + return True + return type(left) is type(right) and left == right + + +def _moved(baseline: Mapping[str, object], perturbed: Mapping[str, object]) -> set[str]: + return { + leaf + for leaf in baseline.keys() | perturbed.keys() + if not _same(baseline.get(leaf, _MISSING), perturbed.get(leaf, _MISSING)) + } + + +@dataclass(slots=True) +class _Instance: + """One declared property on one device, and what the experiment found.""" + + device_id: str + device_type: str + key: RowKey + declaration: Mapping[str, object] + raw: str | None + addressed: bool + extra: bool + effect: set[str] = field(default_factory=set) + fate: Fate = Fate.SILENT + deferred: bool = False + + +def _role(capture: _Capture, device: _Device) -> str: + if device.device_id == capture.root_id: + return "panel" + short = device.device_type.removeprefix(DEVICE_TYPE_PREFIX) + if device.device_type.startswith(TYPE_LUGS): + direction = device.values.get("info/direction", "").strip().lower() + return f"lugs-{direction}" if direction else "lugs" + return short + + +def _instances(capture: _Capture, adapter: SchemaOneAdapter) -> list[_Instance]: + addressed = addressed_rows(devices_from_tree(capture.retained())) + # The snapshot holds one BESS and one MID, the first of each the adapter finds. + kept = {adapter.find_node_by_type(TYPE_BESS), adapter.find_node_by_type(TYPE_MID)} + found: list[_Instance] = [] + for device_id in capture.order: + device = capture.devices[device_id] + extra = device.device_type in (TYPE_BESS, TYPE_MID) and device_id not in kept + for node_property, declaration in device.declared.items(): + node_id, property_id = node_property.split("/", 1) + found.append( + _Instance( + device_id=device_id, + device_type=device.device_type, + key=RowKey(_role(capture, device), node_property), + declaration=declaration, + raw=device.values.get(node_property), + addressed=is_addressed(addressed, device.device_type, node_id, property_id), + extra=extra, + ) + ) + return found + + +def _reference_ids(capture: _Capture) -> list[str]: + """Devices a ``*-device-id`` or ``*-entity`` property could name: every child but circuits and lugs.""" + return [ + device_id + for device_id in capture.order + if device_id != capture.root_id + and capture.devices[device_id].device_type != TYPE_CIRCUIT + and not capture.devices[device_id].device_type.startswith(TYPE_LUGS) + ] + + +def _measure(capture: _Capture, instances: list[_Instance], baseline: Mapping[str, object]) -> None: + references = _reference_ids(capture) + session = _replay(capture) + for instance in instances: + topic = f"{HOMIE_DOMAIN}/{HOMIE_VERSION}/{instance.device_id}/{instance.key.node_property}" + property_id = instance.key.node_property.split("/", 1)[1] + # An unvalued property cannot be returned to unvalued, so it is perturbed on + # a replay of its own, which is discarded; each candidate replaces the last, + # as a later retained value does. + target = session if instance.raw is not None else _replay(capture) + for candidate in _candidates(instance.declaration, instance.raw, property_id, references): + target.handle_message(topic, candidate) + instance.effect |= _moved(baseline, _flatten(target)) + if instance.raw is not None: + session.handle_message(topic, instance.raw) + unrestored = _moved(baseline, _flatten(session)) + if unrestored: + raise AssertionError(f"republishing the captured values did not restore the session: {sorted(unrestored)[:5]}") + + +# --- classification -------------------------------------------------------- + +_OFF_SNAPSHOT: Final = set(_CONSUMED_OFF_SNAPSHOT) + + +def _classify(instance: _Instance, rules: CellRules) -> None: + effect = instance.effect + if any(not leaf.startswith(("ext[", "adopted[", "ctl.", "ready.")) for leaf in effect): + instance.fate = Fate.MAPPED + elif any(leaf.startswith("ext[") for leaf in effect): + instance.fate = Fate.EXTENSION + elif any(leaf.startswith("adopted[") for leaf in effect): + instance.fate = Fate.ADOPTED + elif effect: + instance.fate = Fate.CONTROL + elif _drop_reason(instance) is not None: + instance.fate = Fate.INTENTIONAL + elif instance.raw is not None: + instance.deferred = instance.key in rules.deferred + instance.fate = Fate.INTENTIONAL if instance.deferred else Fate.SILENT + else: + instance.fate = Fate.LATENT if instance.addressed else Fate.INERT + + +def _drop_reason(instance: _Instance) -> DropReason | None: + if instance.extra: + return DropReason.EXTRA_INSTANCE + node_id, property_id = instance.key.node_property.split("/", 1) + if is_addressed(_OFF_SNAPSHOT, instance.device_type, node_id, property_id): + return DropReason.OFF_SNAPSHOT + if node_id in (ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE) and not instance.addressed: + return DropReason.IDENTITY_OR_TOPOLOGY + return None + + +def _promote_conditional(instances: list[_Instance]) -> None: + """An unvalued instance with no effect, where the same row reaches the snapshot through another. + + Its read is conditional on the instance's own topology or values, such as a + ``feeds-device-status`` on a circuit that feeds nothing, so it takes the + row's fate rather than reading as latent or inert. + """ + groups: dict[RowKey, list[_Instance]] = {} + for instance in instances: + groups.setdefault(instance.key, []).append(instance) + for group in groups.values(): + reached = Counter(instance.fate for instance in group if instance.fate in _REACHES) + if not reached: + continue + fate = max(sorted(reached, key=_FATE_ORDER.__getitem__), key=reached.__getitem__) + for instance in group: + if instance.raw is None and instance.fate in (Fate.LATENT, Fate.INERT): + instance.fate = fate + + +# --- value checks ---------------------------------------------------------- + + +def _observe(raw: str, value: object) -> str: + """Which transform turns ``raw`` into ``value``.""" + if value is None: + return "valued -> None" + if isinstance(value, bool): + return TransformKind.BOOL if value == (raw.strip().lower() == "true") else "bool(other)" + if isinstance(value, int | float): + number = _number(raw) + if number is None: + return "number from an unparseable value" + if isinstance(value, float) and value == 0.0 and math.copysign(1.0, value) < 0: + return "negative zero" + if value == number: + return TransformKind.INT if isinstance(value, int) else TransformKind.IDENTITY + if number != 0.0 and value == -number: + return TransformKind.NEGATE + return "number(other)" + if isinstance(value, str): + return TransformKind.LITERAL if value == raw else "string(other)" + return "other" + + +def _compatible(observed: str, expected: TransformKind, raw: str) -> bool: + if expected == TransformKind.DERIVED or observed == expected: + return True + # A zero reads the same under identity and negation. + if expected == TransformKind.NEGATE and observed == TransformKind.IDENTITY and _number(raw) == 0.0: + return True + return expected == TransformKind.IDENTITY and observed == TransformKind.INT + + +def _check_values( + capture: _Capture, + instances: Iterable[_Instance], + baseline: Mapping[str, object], + rules: CellRules, + failures: dict[str, set[RowKey]], + notes: dict[str, str], +) -> set[str]: + mismatched: set[str] = set() + for instance in instances: + if instance.fate not in (Fate.MAPPED, Fate.EXTENSION) or instance.raw is None: + # An unvalued source's leaves are the leaf audit's to judge. + continue + raw = instance.raw + for leaf in sorted(instance.effect): + if leaf.startswith(("ctl.", "ready.", "adopted[")) or leaf not in baseline: + continue + if _instance(leaf) == raw: + continue # this property is the instance's snapshot key + value = baseline[leaf] + problem: str | None = None + if leaf.startswith("ext["): + if value != raw: + problem = f"extension value {value!r} is not the wire value {raw!r}" + else: + transform = rules.transform_for(instance.key, family(leaf)) + if transform is None: + problem = f"no documented transform from {instance.key.node_property}" + else: + expected = transform.kind_for(capture.firmware) + observed = _observe(raw, value) + if not _compatible(observed, expected, raw): + problem = f"{raw!r} -> {value!r} is {observed}, documented {expected} ({transform.cite})" + if problem is not None: + mismatched.add(leaf) + failures.setdefault(leaf, set()).add(instance.key) + notes[leaf] = f"{instance.key.role} {instance.key.node_property}: {problem}" + return mismatched + + +# --- the leaf audit --------------------------------------------------------- + + +def _audit_leaves( + capture: _Capture, + instances: Iterable[_Instance], + baseline: Mapping[str, object], + rules: CellRules, + failures: dict[str, set[RowKey]], + notes: dict[str, str], +) -> tuple[set[str], set[str]]: + """Leaves holding a value no valued wire property moves, and the derivations that account for the rest.""" + sources: dict[str, list[_Instance]] = {} + for instance in instances: + for leaf in instance.effect: + sources.setdefault(leaf, []).append(instance) + fabricated: set[str] = set() + used: set[str] = set() + for leaf, value in baseline.items(): + if leaf.startswith(("ready.", "ext[", "adopted[")) or value is None or value in ((), ""): + continue + owners = sources.get(leaf, []) + if any(owner.raw is not None for owner in owners): + continue + device_id = _instance(leaf) + device = None if device_id is None else capture.devices.get(device_id) + derivation = rules.derivation_for(family(leaf), value, None if device is None else device.values) + if derivation is not None: + used.add(derivation.family) + continue + fabricated.add(leaf) + failures.setdefault(leaf, set()).update(owner.key for owner in owners) + unvalued = ", ".join(sorted({f"{owner.device_id} {owner.key.node_property}" for owner in owners})) + notes[leaf] = f"{value!r} with {'only unvalued sources: ' + unvalued if owners else 'no wire source'}" + return fabricated, used + + +# --- a cell ---------------------------------------------------------------- + + +def _grid_forming_names(capture: _Capture) -> dict[str, str | None]: + """What the snapshot names each device while the MID says that device forms the grid.""" + grid_forming = f"{NODE_GRID}/{PROP_GRID_FORMING_ENTITY}" + mid = next((device_id for device_id in capture.order if capture.devices[device_id].device_type == TYPE_MID), None) + if mid is None or grid_forming not in capture.devices[mid].declared: + return {} + probe = _replay(capture) + topic = f"{HOMIE_DOMAIN}/{HOMIE_VERSION}/{mid}/{grid_forming}" + names: dict[str, str | None] = {} + for device_id in capture.order: + if device_id == capture.root_id: + continue + probe.handle_message(topic, device_id) + snapshot = probe.build_snapshot() + names[device_id] = None if snapshot.mid is None else snapshot.mid.grid_forming_device_name + return names + + +def run_cell(tree_path: Path, rules: CellRules) -> FidelityReport: + """Measure one captured tree against ``rules``.""" + capture = _load(tree_path) + adapter = _replay(capture) + baseline = _flatten(adapter) + if baseline["ready.is_ready"] is not True: + raise AssertionError(f"{tree_path.name}: the replayed tree never became ready") + + instances = _instances(capture, adapter) + _measure(capture, instances, baseline) + for instance in instances: + _classify(instance, rules) + _promote_conditional(instances) + + failures: dict[str, set[RowKey]] = {} + notes: dict[str, str] = {} + mismatched = _check_values(capture, instances, baseline, rules, failures, notes) + fabricated, used = _audit_leaves(capture, instances, baseline, rules, failures, notes) + + fates: dict[RowKey, set[Fate]] = {} + for instance in instances: + fates.setdefault(instance.key, set()).add(instance.fate) + return FidelityReport( + rows={key: tuple(sorted(found, key=_FATE_ORDER.__getitem__)) for key, found in sorted(fates.items())}, + silently_dropped=tuple(sorted({instance.key for instance in instances if instance.fate == Fate.SILENT})), + deferred=tuple(sorted({instance.key for instance in instances if instance.deferred})), + fabricated=tuple(sorted(fabricated)), + mismatched=tuple(sorted(mismatched)), + declared_property_instances=len(instances), + instance_fates=dict(Counter(instance.fate for instance in instances)), + leaf_rows={leaf: frozenset(keys) for leaf, keys in failures.items()}, + notes=notes, + derivations_used=frozenset(used), + grid_forming_names=_grid_forming_names(capture), + ) diff --git a/tests/adapter_fidelity/rules.py b/tests/adapter_fidelity/rules.py new file mode 100644 index 0000000..119f5fd --- /dev/null +++ b/tests/adapter_fidelity/rules.py @@ -0,0 +1,354 @@ +"""What is right for the MAIN 32 cells, and the code that makes each entry so. + +Every citation names a symbol in ``span_panel_api_schema_1`` (or in +``span_panel_api`` where it says so) rather than a line, so a citation outlives +the edit that moves the code. + +The transform table covers every row of the library's own `_PROPERTY_FIELD_MAP` +and both lugs tables, which `test_every_mapped_field_has_a_documented_transform` +holds it to: a field the library starts mapping cannot reach a snapshot without a +transform documented here. The derivations are only the ones these captures +need, which `test_every_derivation_is_needed` holds them to: a justification no +leaf needs is how a list of reasons turns into an allowlist. +""" + +from __future__ import annotations + +from typing import Final + +from .harness import ANY_LEAF, CellRules, Derivation, DropReason, RowKey, Transform, TransformKind + +_IDENTITY: Final = TransformKind.IDENTITY +_NEGATE: Final = TransformKind.NEGATE +_INT: Final = TransformKind.INT +_LITERAL: Final = TransformKind.LITERAL +_BOOL: Final = TransformKind.BOOL +_DERIVED: Final = TransformKind.DERIVED + +BESS_METER_DISCHARGE_POSITIVE_FROM_BUILD: Final = 202639 +"""Mirrors `devices.BESS_METER_DISCHARGE_POSITIVE_FROM_BUILD`, restated so the rule is checked rather than borrowed.""" + + +def _transforms() -> dict[RowKey, dict[str, Transform]]: + table: dict[RowKey, dict[str, Transform]] = {} + + def add(role: str, node_property: str, family: str, kind: TransformKind, cite: str) -> None: + table.setdefault(RowKey(role, node_property), {})[family] = Transform(kind, cite) + + # --- panel ------------------------------------------------------------- + panel_fields = "panel.PanelFields" + add("panel", "info/firmware-version", "panel.firmware_version", _LITERAL, panel_fields) + add( + "panel", + "info/firmware-version", + "battery.power_w", + _DERIVED, + "devices.build_battery: the release build in the firmware version picks the BESS meter's frame", + ) + add("panel", "info/vendor-name", "panel.vendor_name", _LITERAL, panel_fields) + add("panel", "info/model", "panel.model", _LITERAL, panel_fields) + add("panel", "info/model", "panel.panel_size", _DERIVED, "panel.panel_size_from_model and const.PANEL_SIZE_BY_MODEL") + add("panel", "info/hardware-version", "panel.hardware_version", _LITERAL, panel_fields) + add("panel", "info/serial-number", "panel.serial_number", _LITERAL, panel_fields) + add("panel", "status/relay", "panel.main_relay_state", _LITERAL, panel_fields) + add("panel", "door/state", "panel.door_state", _LITERAL, panel_fields) + add("panel", "status/ethernet", "panel.eth0_link", _BOOL, panel_fields) + add("panel", "status/wifi", "panel.wlan_link", _BOOL, panel_fields) + add("panel", "status/wifi-ssid", "panel.wifi_ssid", _LITERAL, panel_fields) + add("panel", "status/cloud-connection", "panel.vendor_cloud", _LITERAL, panel_fields) + add( + "panel", + "status/cloud-connection", + "panel.wwan_link", + _DERIVED, + "panel.PanelFields: the cloud link is CONNECTED, as flat reported it", + ) + add("panel", "meter/voltage-a", "panel.l1_voltage", _IDENTITY, panel_fields) + add("panel", "meter/voltage-b", "panel.l2_voltage", _IDENTITY, panel_fields) + add("panel", "breaker/rating", "panel.main_breaker_rating_a", _INT, panel_fields) + for flow in ("pv", "battery", "grid", "site"): + add( + "panel", + f"power-flows/{flow}", + f"panel.power_flow_{flow}", + _IDENTITY, + f"{panel_fields}: passed through untouched", + ) + add( + "panel", + "power-flows/battery", + "battery.power_w", + _DERIVED, + "devices.build_battery: compared in sign with the BESS meter where the firmware names no release build", + ) + add( + "panel", + "power-flows/grid", + ANY_LEAF, + _DERIVED, + "panel.resolve_islanding_state: grid power decides on-grid only where no MID is published", + ) + add("panel", "shed/policy", "panel.shed_policy", _LITERAL, panel_fields) + for policy_field in ("shed_policy_algorithm", "shed_soc_threshold_shed_percent", "shed_soc_threshold_release_percent"): + add("panel", "shed/policy", f"panel.{policy_field}", _DERIVED, "panel._shed_policy parses the policy document") + add( + "panel", + "shed/asserted-islanding-state", + ANY_LEAF, + _DERIVED, + "panel.resolve_islanding_state: an assertion in force outranks the MID", + ) + for forecast in ( + "time-to-priority-shed", + "total-time-remaining", + "full-charge-time-to-priority-shed", + "full-charge-total-time-remaining", + ): + add("panel", f"shed-forecast/{forecast}", f"panel.shed_{forecast.replace('-', '_')}_min", _INT, panel_fields) + add("panel", "shed-forecast/confidence", "panel.shed_forecast_confidence", _LITERAL, panel_fields) + pcs = "panel.build_pcs" + add("panel", "pcs/enabled", "pcs.enabled", _BOOL, pcs) + add("panel", "pcs/active", "pcs.active", _BOOL, pcs) + add("panel", "pcs/import-limit", "pcs.import_limit_a", _IDENTITY, pcs) + add("panel", "pcs/binding-constraint", "pcs.binding_constraint", _LITERAL, pcs) + for source in ("feed", "operator", "off-grid", "requested"): + name = source.replace("-", "_") + add( + "panel", f"pcs/{source}-import-limit", f"pcs.{name}_import_limit_a", _IDENTITY, f"panel._limit_triplet via {pcs}" + ) + add( + "panel", + f"pcs/{source}-import-limit-enablement", + f"pcs.{name}_import_limit_enablement", + _LITERAL, + f"panel._limit_triplet via {pcs}", + ) + add( + "panel", + f"pcs/{source}-import-limit-active", + f"pcs.{name}_import_limit_active", + _BOOL, + f"panel._limit_triplet via {pcs}", + ) + + # --- circuit ----------------------------------------------------------- + circuit = "circuits.build_circuit" + add( + "circuit", + "meter/active-power", + "circuit.instant_power_w", + _NEGATE, + f"{circuit}: a load reads negative in the enclosure frame", + ) + add("circuit", "meter/imported-energy", "circuit.produced_energy_wh", _IDENTITY, f"{circuit}: the accumulators swap") + add("circuit", "meter/exported-energy", "circuit.consumed_energy_wh", _IDENTITY, f"{circuit}: the accumulators swap") + add("circuit", "meter/current", "circuit.current_a", _IDENTITY, circuit) + add("circuit", "breaker/rating", "circuit.breaker_rating_a", _IDENTITY, circuit) + add("circuit", "breaker/poles", "circuit.is_240v", _DERIVED, f"{circuit}: two poles or more") + add("circuit", "info/name", "circuit.name", _LITERAL, circuit) + add("circuit", "info/spaces", "circuit.tabs", _DERIVED, "circuits._tabs parses the list of spaces") + add("circuit", "switch/relay", "circuit.relay_state", _LITERAL, circuit) + add("circuit", "switch/relay-requester", "circuit.relay_requester", _LITERAL, circuit) + add("circuit", "switch/relay-controllable", "circuit.is_user_controllable", _BOOL, circuit) + add("circuit", "switch/relay-controllable", "circuit.always_on", _DERIVED, f"{circuit}: not relay-controllable") + add( + "circuit", + "switch/relay-controllable", + "circuit.is_sheddable", + _DERIVED, + f"{circuit}: a shed priority and a controllable relay", + ) + add("circuit", "load-shed/priority", "circuit.priority", _LITERAL, circuit) + add( + "circuit", + "load-shed/priority", + "circuit.is_sheddable", + _DERIVED, + f"{circuit}: a shed priority and a controllable relay", + ) + add("circuit", "pcs/managed", "circuit.pcs_managed", _BOOL, circuit) + add("circuit", "pcs/priority", "circuit.pcs_priority", _INT, circuit) + add( + "circuit", + "connection/feeds-device-id", + ANY_LEAF, + _DERIVED, + "snapshot.build_snapshot and devices.feed_circuit_ids: the circuit's label, and the fed DER's feeding circuit, key and position", + ) + for der in ("evse", "pv", "pv_inverter"): + add( + "circuit", + "connection/feeds-device-status", + f"{der}.connected", + _DERIVED, + "devices.feed_connection_statuses and devices._connected: OK is a working link", + ) + + # --- lugs -------------------------------------------------------------- + lugs_meter = "panel.PanelFields: the lugs read import-positive and pass through" + for role, power, consumed, produced, current_a, current_b in ( + ( + "lugs-upstream", + "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", + ), + ( + "lugs-downstream", + "panel.feedthrough_power_w", + "panel.feedthrough_energy_consumed_wh", + "panel.feedthrough_energy_produced_wh", + "panel.downstream_l1_current_a", + "panel.downstream_l2_current_a", + ), + ): + add(role, "meter/active-power", power, _IDENTITY, lugs_meter) + add(role, "meter/imported-energy", consumed, _IDENTITY, lugs_meter) + add(role, "meter/exported-energy", produced, _IDENTITY, lugs_meter) + add(role, "meter/current-a", current_a, _IDENTITY, lugs_meter) + add(role, "meter/current-b", current_b, _IDENTITY, lugs_meter) + add(role, "info/direction", ANY_LEAF, _DERIVED, "panel.find_lugs: the direction decides which lugs are which") + add( + role, + "connection/fed-by-device-id", + ANY_LEAF, + _DERIVED, + "panel.PanelFields (lugs_at_service_entrance), devices.connection_status_for and devices.resolve_relative_position", + ) + add( + role, + "connection/fed-by-device-status", + "battery.connected", + _DERIVED, + "devices.connection_status_for and devices._connected: OK is a working link", + ) + add( + role, + "connection/feeds-device-id", + ANY_LEAF, + _DERIVED, + "devices.resolve_relative_position: a DER fed through the feedthrough is in the panel", + ) + + # --- BESS -------------------------------------------------------------- + battery = "devices.build_battery" + add("bess", "soc/soc", "battery.soe_percentage", _IDENTITY, battery) + add("bess", "soc/soe", "battery.soe_kwh", _IDENTITY, battery) + add("bess", "info/vendor-name", "battery.vendor_name", _LITERAL, battery) + add("bess", "info/model", "battery.model", _LITERAL, battery) + add("bess", "info/part-number", "battery.part_number", _LITERAL, battery) + add("bess", "info/serial-number", "battery.serial_number", _LITERAL, battery) + add("bess", "info/firmware-version", "battery.software_version", _LITERAL, battery) + add("bess", "info/nameplate-capacity", "battery.nameplate_capacity_kwh", _IDENTITY, battery) + add("bess", "status/communication-state", "battery.communication_state", _LITERAL, battery) + table.setdefault(RowKey("bess", "meter/active-power"), {})["battery.power_w"] = Transform( + _IDENTITY, + f"{battery}: discharge-positive, and the wire is too from release build " + f"{BESS_METER_DISCHARGE_POSITIVE_FROM_BUILD}; charge-positive before it, so negated", + earlier=(BESS_METER_DISCHARGE_POSITIVE_FROM_BUILD, _NEGATE), + ) + + # --- MID --------------------------------------------------------------- + mid = "devices.build_mid" + add("mid", "info/vendor-name", "mid.vendor_name", _LITERAL, mid) + add("mid", "info/model", "mid.model", _LITERAL, mid) + add("mid", "info/serial-number", "mid.serial_number", _LITERAL, mid) + add("mid", "info/serial-number", "mid.node_id", _DERIVED, f"{mid}: the serial, else the device id") + add("mid", "info/firmware-version", "mid.software_version", _LITERAL, mid) + add("mid", "info/hardware-version", "mid.hardware_version", _LITERAL, mid) + add("mid", "grid/islanding-state", "mid.islanding_state", _LITERAL, mid) + add("mid", "grid/islanding-state", "panel.grid_state", _LITERAL, "panel.PanelFields: islanding-state, not grid-state") + add( + "mid", + "grid/islanding-state", + ANY_LEAF, + _DERIVED, + "panel.resolve_islanding_state, panel.resolve_dsm_state and panel.resolve_run_config", + ) + add("mid", "grid/grid-state", "mid.grid_state", _LITERAL, mid) + add("mid", "grid/grid-forming-entity", "mid.grid_forming_entity", _LITERAL, mid) + add( + "mid", + "grid/grid-forming-entity", + ANY_LEAF, + _DERIVED, + "panel.resolve_run_config, panel.resolve_dominant_power_source and panel.resolve_grid_forming_device_name", + ) + + # --- PV ---------------------------------------------------------------- + for subject in ("pv", "pv_inverter"): + pv = f"devices.build_pv ({'the lone inverter' if subject == 'pv' else 'each inverter'})" + add("pv", "info/vendor-name", f"{subject}.vendor_name", _LITERAL, pv) + add("pv", "info/model", f"{subject}.model", _LITERAL, pv) + add("pv", "info/serial-number", f"{subject}.serial_number", _LITERAL, pv) + add("pv", "info/firmware-version", f"{subject}.software_version", _LITERAL, pv) + add("pv", "info/nominal-power", f"{subject}.nameplate_capacity_w", _IDENTITY, pv) + + # --- EVSE -------------------------------------------------------------- + evse = "devices.build_evse" + add("evse", "status/status", "evse.status", _LITERAL, evse) + add("evse", "switch/lock-state", "evse.lock_state", _LITERAL, evse) + add("evse", "meter/advertised-current", "evse.advertised_current_a", _IDENTITY, evse) + add("evse", "info/vendor-name", "evse.vendor_name", _LITERAL, evse) + add("evse", "info/model", "evse.model", _LITERAL, evse) + add("evse", "info/part-number", "evse.part_number", _LITERAL, evse) + add("evse", "info/serial-number", "evse.serial_number", _LITERAL, evse) + add( + "evse", "info/serial-number", ANY_LEAF, _DERIVED, "snapshot.harmonised_evse_keys: the charger is keyed by its serial" + ) + add("evse", "info/firmware-version", "evse.software_version", _LITERAL, evse) + return table + + +_DERIVATIONS: Final = ( + Derivation("circuit.circuit_id", "circuits.build_circuit: the circuit's device id"), + Derivation( + "circuit.device_type", + "snapshot.build_snapshot labels a circuit by the PV or EVSE its connection/feeds-device-id names, else 'circuit'", + values=("circuit",), + ), + Derivation( + "circuit.is_never_backup", + "circuits.priority_is_settable: `$settable` on the load-shed/priority declaration, not a value", + ), + Derivation( + "circuit.is_sheddable", + "circuits.build_circuit: not sheddable when the published priority and relay-controllable rule it out together", + values=(False,), + inputs=("load-shed/priority", "switch/relay-controllable"), + ), + Derivation( + "ctl.priority_target", + "adapter.SchemaOneAdapter.set_circuit_priority_target: a topic, offered where the declaration is `$settable`", + ), + Derivation( + "ctl.dps_target", + "adapter.SchemaOneAdapter.set_dominant_power_source_target: a topic, offered where the declaration is `$settable`", + ), + Derivation("pv.device_id", "devices.build_pv: the inverter's device id"), + Derivation("pv.node_id", "devices.pv_inverter_key: the feeding circuit's id, else the inverter's device id"), + Derivation("pv_inverter.device_id", "devices.build_pv: the inverter's device id"), + Derivation("pv_inverter.node_id", "devices.pv_inverter_key: the feeding circuit's id, else the inverter's device id"), +) + +_DROPS: Final = { + DropReason.OFF_SNAPSHOT: ( + "field_metadata._CONSUMED_OFF_SNAPSHOT names the code that reads each of these by a route no snapshot field shows" + ), + DropReason.IDENTITY_OR_TOPOLOGY: ( + "extension.build_extension_properties skips the info and connection nodes " + "(ADOPTION_IDENTITY_NODE and ADOPTION_TOPOLOGY_NODE in span_panel_api.models): identity and topology are never readings" + ), + DropReason.EXTRA_INSTANCE: "snapshot.TreeRoles keeps the first BESS and the first MID; the snapshot holds one of each", +} + +_DEFERRED: Final = { + RowKey("lugs-upstream", "connection/fed-by-device-status"): ( + "devices.connection_status_for reads it only for the battery the upstream lugs name as their source; " + "the status of a link to anything else, such as a panel feeding this one, reaches no snapshot field yet" + ), +} + +MAIN32_RULES: Final = CellRules(transforms=_transforms(), derivations=_DERIVATIONS, drops=_DROPS, deferred=_DEFERRED) diff --git a/tests/adapter_fidelity/test_harness.py b/tests/adapter_fidelity/test_harness.py new file mode 100644 index 0000000..ab610ab --- /dev/null +++ b/tests/adapter_fidelity/test_harness.py @@ -0,0 +1,142 @@ +"""The harness fails where the adapter is unfaithful, not only passes where it is faithful. + +A gate that cannot fail proves nothing. So each kind of failure is planted in the +adapter's snapshot, over a small cut of the public r202639 capture, and the +harness must name it: a value with no wire behind it, one behind only unvalued +properties, one under the wrong transform, and a published property that reaches +nothing. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import replace +import json +from pathlib import Path + +from ebus_sdk.homie import DiscoveredDevice +import pytest + +from span_panel_api.models import SpanPanelSnapshot +from span_panel_api_schema_1 import adapter as adapter_module +from span_panel_api_schema_1.const import TYPE_CIRCUIT, TYPE_PV +from span_panel_api_schema_1.snapshot import build_snapshot + +from .harness import FidelityReport, RowKey, run_cell +from .rules import MAIN32_RULES + +CAPTURE = Path(__file__).parent.parent / "fixtures" / "main32_r202639-tree-v1.json" + +Plant = Callable[[SpanPanelSnapshot], SpanPanelSnapshot] + + +def _mapping(value: object) -> dict[str, object]: + if not isinstance(value, Mapping): + raise TypeError(f"expected a JSON object, got {value!r}") + return {str(key): item for key, item in value.items()} + + +@pytest.fixture +def small_capture(tmp_path: Path) -> Path: + """The capture with one circuit, and no PV: the panel, its lugs, its battery and MID, and that circuit.""" + tree = _mapping(json.loads(CAPTURE.read_text(encoding="utf-8"))) + devices = _mapping(tree["devices"]) + circuits = sorted( + device_id + for device_id, entry in devices.items() + if _mapping(_mapping(entry)["description"]).get("type") == TYPE_CIRCUIT + ) + dropped = set(circuits[1:]) | { + device_id for device_id, entry in devices.items() if _mapping(_mapping(entry)["description"]).get("type") == TYPE_PV + } + kept: dict[str, object] = {} + for device_id, entry in devices.items(): + if device_id in dropped: + continue + record = _mapping(entry) + description = _mapping(record["description"]) + children = description.get("children") + if isinstance(children, list): + description["children"] = [child for child in children if child not in dropped] + kept[device_id] = {**record, "description": description} + path = tmp_path / "small-tree-v1.json" + path.write_text(json.dumps({**tree, "devices": kept}), encoding="utf-8") + return path + + +@pytest.fixture +def plant(monkeypatch: pytest.MonkeyPatch) -> Callable[[Plant], None]: + """Change every snapshot the adapter builds.""" + + def install(change: Plant) -> None: + def planted(panel: DiscoveredDevice, children: list[DiscoveredDevice]) -> SpanPanelSnapshot: + return change(build_snapshot(panel, children)) + + monkeypatch.setattr(adapter_module, "build_snapshot", planted) + + return install + + +def _clean(report: FidelityReport) -> bool: + return not (report.silently_dropped or report.fabricated or report.mismatched) + + +def test_the_unchanged_adapter_passes(small_capture: Path) -> None: + """The baseline the planted failures are measured against.""" + assert _clean(run_cell(small_capture, MAIN32_RULES)) + + +def test_a_constant_with_no_wire_source_is_fabricated(small_capture: Path, plant: Callable[[Plant], None]) -> None: + plant(lambda snapshot: replace(snapshot, uptime_s=0)) + report = run_cell(small_capture, MAIN32_RULES) + assert report.fabricated == ("panel.uptime_s",) + assert report.unowned_leaves() == {"panel.uptime_s"} + + +def test_a_value_behind_only_unvalued_properties_is_fabricated(small_capture: Path, plant: Callable[[Plant], None]) -> None: + """The capture declares the MID's model and never values it.""" + + def model_or_placeholder(snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: + assert snapshot.mid is not None + return replace(snapshot, mid=replace(snapshot.mid, model=snapshot.mid.model or "placeholder")) + + plant(model_or_placeholder) + report = run_cell(small_capture, MAIN32_RULES) + assert report.fabricated == ("mid.model",) + assert report.failing_rows() == {RowKey("mid", "info/model")} + assert report.unowned_leaves() == frozenset() + + +def test_a_value_under_the_wrong_transform_is_mismatched(small_capture: Path, plant: Callable[[Plant], None]) -> None: + """Circuit power left in the enclosure frame, where a load reads negative.""" + + def unnegated(snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: + circuits = { + circuit_id: replace( + circuit, instant_power_w=None if circuit.instant_power_w is None else -circuit.instant_power_w + ) + for circuit_id, circuit in snapshot.circuits.items() + } + return replace(snapshot, circuits=circuits) + + plant(unnegated) + report = run_cell(small_capture, MAIN32_RULES) + assert [leaf.rsplit(".", 1)[1] for leaf in report.mismatched] == ["instant_power_w"] + assert report.failing_rows() == {RowKey("circuit", "meter/active-power")} + + +def test_a_published_property_that_reaches_nothing_is_silently_dropped( + small_capture: Path, plant: Callable[[Plant], None] +) -> None: + plant(lambda snapshot: replace(snapshot, battery=replace(snapshot.battery, vendor_name=None))) + report = run_cell(small_capture, MAIN32_RULES) + assert report.silently_dropped == (RowKey("bess", "info/vendor-name"),) + assert report.deferred == () + + +def test_a_deferred_row_is_reported_apart_from_the_silent_drops(small_capture: Path, plant: Callable[[Plant], None]) -> None: + plant(lambda snapshot: replace(snapshot, battery=replace(snapshot.battery, vendor_name=None))) + key = RowKey("bess", "info/vendor-name") + report = run_cell(small_capture, replace(MAIN32_RULES, deferred={key: "planted for this test"})) + assert report.silently_dropped == () + assert report.deferred == (key,) diff --git a/tests/adapter_fidelity/test_main32_fidelity.py b/tests/adapter_fidelity/test_main32_fidelity.py new file mode 100644 index 0000000..1a39adf --- /dev/null +++ b/tests/adapter_fidelity/test_main32_fidelity.py @@ -0,0 +1,137 @@ +"""Every property the public MAIN 32 captures declare reaches the snapshot, faithfully. + +Each capture is replayed through the real adapter; each declared property is +perturbed to see which snapshot leaves it moves. A property that moves nothing and +is not dropped on purpose by code that says so is silently lost. A value with no +wire behind it, or behind only unvalued properties, is fabricated. + +Two cells: the MAIN 32 capture on spanos3/r202639/03, and the MAIN 32 capture on +spanos3/r202633/02, both published with the eBus panel simulator. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import cache +import hashlib +from pathlib import Path + +import pytest + +from span_panel_api_schema_1.const import DEVICE_TYPE_PREFIX, NODE_METER, TYPE_PANEL +from span_panel_api_schema_1.field_metadata import _DOWNSTREAM_LUGS_FIELDS, _PROPERTY_FIELD_MAP, _UPSTREAM_LUGS_FIELDS + +from .harness import DropReason, FidelityReport, RowKey, run_cell +from .rules import MAIN32_RULES + +FIXTURES = Path(__file__).parent.parent / "fixtures" + + +@dataclass(frozen=True, slots=True) +class _Cell: + capture: Path + sha256: str + """Of the file as published at the eBus panel simulator's v0.9.0 tag.""" + deferred: tuple[RowKey, ...] + """The rows this capture values and the library drops today, each deferred in the rules with its reason.""" + + +CELLS = { + "main32_r202639": _Cell( + capture=FIXTURES / "main32_r202639-tree-v1.json", + sha256="bf16d8ce21c2b685496700310286198664c6be1a62ea0fe12b1f15144ebfc086", + deferred=(), + ), + "main32_r202633": _Cell( + capture=FIXTURES / "main32-tree-v1.json", + sha256="0e92bbdca40f7d8d471e8edbfe2b49f986f2995512025bc67ffb4fcf20f7dd30", + # The upstream lugs here are fed by another panel, and report that link OK. + deferred=(RowKey("lugs-upstream", "connection/fed-by-device-status"),), + ), +} + + +@cache +def _report(cell: str) -> FidelityReport: + return run_cell(CELLS[cell].capture, MAIN32_RULES) + + +def _why(report: FidelityReport, leaves: tuple[str, ...]) -> str: + return "\n".join(f"{leaf}: {report.notes[leaf]}" for leaf in leaves) + + +@pytest.mark.parametrize("cell", sorted(CELLS)) +def test_no_property_is_silently_dropped(cell: str) -> None: + assert _report(cell).silently_dropped == () + + +@pytest.mark.parametrize("cell", sorted(CELLS)) +def test_only_the_recorded_rows_are_deferred(cell: str) -> None: + """A deferral that no longer holds fails as surely as a new silent drop.""" + assert _report(cell).deferred == CELLS[cell].deferred + + +@pytest.mark.parametrize("cell", sorted(CELLS)) +def test_no_value_is_fabricated(cell: str) -> None: + report = _report(cell) + assert report.fabricated == (), _why(report, report.fabricated) + + +@pytest.mark.parametrize("cell", sorted(CELLS)) +def test_every_mapped_value_matches_its_documented_transform(cell: str) -> None: + report = _report(cell) + assert report.mismatched == (), _why(report, report.mismatched) + + +@pytest.mark.parametrize("cell", sorted(CELLS)) +def test_the_matrix_covers_every_declared_property(cell: str) -> None: + report = _report(cell) + assert sum(1 for fates in report.rows.values() if fates) == len(report.rows) + assert report.declared_property_instances > 0 + assert sum(report.instance_fates.values()) == report.declared_property_instances + + +@pytest.mark.parametrize("cell", sorted(CELLS)) +def test_no_leaf_is_fabricated_outside_any_row(cell: str) -> None: + assert _report(cell).unowned_leaves() == frozenset() + + +@pytest.mark.xfail(strict=True, reason="fixed later in this PR") +@pytest.mark.parametrize("cell", sorted(CELLS)) +def test_no_device_is_named_by_its_own_id(cell: str) -> None: + """While a device forms the grid, the name shown for it is never its device id. + + Both captures publish each non-root device's `$description.name` as its id. + """ + names = _report(cell).grid_forming_names + assert names + assert sorted(device_id for device_id, name in names.items() if name == device_id) == [] + + +@pytest.mark.parametrize("cell", sorted(CELLS)) +def test_the_capture_is_the_published_one(cell: str) -> None: + assert hashlib.sha256(CELLS[cell].capture.read_bytes()).hexdigest() == CELLS[cell].sha256 + + +def test_every_intentional_drop_and_derivation_cites_code() -> None: + assert set(MAIN32_RULES.drops) == set(DropReason) + assert all(reason.strip() for reason in MAIN32_RULES.justifications.values()) + + +def _role(device_type: str) -> str: + return "panel" if device_type == TYPE_PANEL else device_type.removeprefix(DEVICE_TYPE_PREFIX) + + +def test_every_mapped_field_has_a_documented_transform() -> None: + """A field the library starts mapping cannot reach a snapshot unchecked.""" + mapped = {(RowKey(_role(device_type), f"{node}/{prop}"), path) for device_type, node, prop, path in _PROPERTY_FIELD_MAP} + for role, lugs_fields in (("lugs-upstream", _UPSTREAM_LUGS_FIELDS), ("lugs-downstream", _DOWNSTREAM_LUGS_FIELDS)): + mapped |= {(RowKey(role, f"{NODE_METER}/{prop}"), path) for prop, path in lugs_fields} + missing = sorted((key, path) for key, path in mapped if path not in MAIN32_RULES.transforms.get(key, {})) + assert missing == [] + + +def test_every_derivation_is_needed() -> None: + """A derivation no leaf needs is a standing excuse for the next fabrication.""" + used = frozenset().union(*(_report(cell).derivations_used for cell in CELLS)) + assert {derivation.family for derivation in MAIN32_RULES.derivations} == used diff --git a/tests/fixtures/main32-tree-v1.README.md b/tests/fixtures/main32-tree-v1.README.md new file mode 100644 index 0000000..e4a48aa --- /dev/null +++ b/tests/fixtures/main32-tree-v1.README.md @@ -0,0 +1,6 @@ +# main32-tree-v1.json + +The device tree a MAIN 32 panel publishes on firmware `spanos3/r202633/02`, as a `tree-v1` snapshot, with its device ids, serial numbers, postal code and Wi-Fi SSID masked and its circuit names made generic. + +It is byte-identical to `tests/fixtures/main32-tree-v1.json` in [electrification-bus/distribution-enclosure-simulator](https://github.com/electrification-bus/distribution-enclosure-simulator) at tag `v0.9.0` (commit +`2dbddf7c507776a08e03a64825454972696d23df`), and is never regenerated here. `tests/adapter_fidelity/test_main32_fidelity.py` pins its SHA-256 and replays it through the schema-1 adapter. diff --git a/tests/fixtures/main32-tree-v1.json b/tests/fixtures/main32-tree-v1.json new file mode 100644 index 0000000..baaa1d5 --- /dev/null +++ b/tests/fixtures/main32-tree-v1.json @@ -0,0 +1,3748 @@ +{ + "devices": { + "fbaae45daa7c145806eb9c5692fec660": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 8", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662261 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "20", + "meter/active-power": "0.0", + "meter/current": "0.1", + "meter/exported-energy": "26593.6", + "meter/imported-energy": "619.4" + }, + "properties": { + "info/name": "Circuit 8", + "info/spaces": "8", + "load-shed/priority": "SOC_THRESHOLD", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "afd57a42ebec7cf6a5083315127698ec": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 15", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662506 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "20", + "meter/active-power": "0.0", + "meter/current": "0.1", + "meter/exported-energy": "46113.1", + "meter/imported-energy": "345.6" + }, + "properties": { + "info/name": "Circuit 15", + "info/spaces": "16", + "load-shed/priority": "SOC_THRESHOLD", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "36afaccafd19b2bfbcb6fcb5d6089399": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 2", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662296 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "20", + "meter/active-power": "-15.5", + "meter/current": "0.2", + "meter/exported-energy": "973252.4", + "meter/imported-energy": "0.5" + }, + "properties": { + "info/name": "Circuit 2", + "info/spaces": "2", + "load-shed/priority": "NEVER", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "88416e5306e0a2d1e528908967f1f9fa": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 14", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662354 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "20", + "meter/active-power": "-3.4", + "meter/current": "0.1", + "meter/exported-energy": "349678.7", + "meter/imported-energy": "705.5" + }, + "properties": { + "info/name": "Circuit 14", + "info/spaces": "14", + "load-shed/priority": "SOC_THRESHOLD", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "45ac025e77f416de0113e7ec0514925d": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 11", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662412 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "15", + "meter/active-power": "-4.5", + "meter/current": "0.1", + "meter/exported-energy": "425794.3", + "meter/imported-energy": "0.0" + }, + "properties": { + "info/name": "Circuit 11", + "info/spaces": "11", + "load-shed/priority": "NEVER", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "e4f6668d42f85d5a0f3300c2a6747290": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 17", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662651 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "20", + "meter/active-power": "-320.3", + "meter/current": "3.6", + "meter/exported-energy": "5762848.5", + "meter/imported-energy": "320.5" + }, + "properties": { + "info/name": "Circuit 17", + "info/spaces": "20", + "load-shed/priority": "NEVER", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "f45b824f00dc3829dd2ed6609d6cf17b": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 16", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662678 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "20", + "meter/active-power": "-3.4", + "meter/current": "0.1", + "meter/exported-energy": "51753.1", + "meter/imported-energy": "201.4" + }, + "properties": { + "info/name": "Circuit 16", + "info/spaces": "18", + "load-shed/priority": "NEVER", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "1ac7fbc901ecb9d0c785dd3234b21a3c": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 10", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662534 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "20", + "meter/active-power": "-3.8", + "meter/current": "0.1", + "meter/exported-energy": "539141.3", + "meter/imported-energy": "182.0" + }, + "properties": { + "info/name": "Circuit 10", + "info/spaces": "10", + "load-shed/priority": "SOC_THRESHOLD", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "1bc992eaeb96482b1d019edecfcc18c2": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 5", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662233 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "15", + "meter/active-power": "5.1", + "meter/current": "0.1", + "meter/exported-energy": "74400.1", + "meter/imported-energy": "31534.6" + }, + "properties": { + "info/name": "Circuit 5", + "info/spaces": "5", + "load-shed/priority": "NEVER", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "9af59a336ebe2cd5c5ae5ff3d8424821": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 3", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662563 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "15", + "meter/active-power": "0.0", + "meter/current": "0.1", + "meter/exported-energy": "43396.0", + "meter/imported-energy": "2.8" + }, + "properties": { + "info/name": "Circuit 3", + "info/spaces": "3", + "load-shed/priority": "OFF_GRID", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "420ee28ea3d7362dad8d341cc93e50b3": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 9", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662733 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "15", + "meter/active-power": "-101.6", + "meter/current": "0.9", + "meter/exported-energy": "274309.6", + "meter/imported-energy": "42.5" + }, + "properties": { + "info/name": "Circuit 9", + "info/spaces": "9", + "load-shed/priority": "NEVER", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "76321a44f69235b4702f74baf0d10aea": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 4", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662443 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "20", + "meter/active-power": "0.0", + "meter/current": "0.1", + "meter/exported-energy": "36111.2", + "meter/imported-energy": "176.1" + }, + "properties": { + "info/name": "Circuit 4", + "info/spaces": "4", + "load-shed/priority": "NEVER", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "2e591e0d0cdc0e8bdbfdccbfc86d14ca": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 18", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662383 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "20", + "meter/active-power": "-10.6", + "meter/current": "0.2", + "meter/exported-energy": "723598.1", + "meter/imported-energy": "1008.9" + }, + "properties": { + "info/name": "Circuit 18", + "info/spaces": "22", + "load-shed/priority": "NEVER", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "e437d9b59344dd03bc2775fb238a5692": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 13", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662325 + }, + "numeric_properties": { + "breaker/poles": "2", + "breaker/rating": "20", + "meter/active-power": "0.0", + "meter/current": "0.1", + "meter/exported-energy": "62960.0", + "meter/imported-energy": "28436.1" + }, + "properties": { + "info/name": "Circuit 13", + "info/spaces": "13,15", + "load-shed/priority": "NEVER", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "26ae0676844ed69346cccae68c2d516a": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 19", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662592 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "20", + "meter/active-power": "0.0", + "meter/current": "0.1", + "meter/exported-energy": "3440.7", + "meter/imported-energy": "2203.9" + }, + "properties": { + "info/name": "Circuit 19", + "info/spaces": "24", + "load-shed/priority": "SOC_THRESHOLD", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "367c30fcffa7ac59fe428d7c426bd19d": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 1", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662708 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "15", + "meter/active-power": "0.0", + "meter/current": "0.1", + "meter/exported-energy": "14525.6", + "meter/imported-energy": "50550.5" + }, + "properties": { + "info/name": "Circuit 1", + "info/spaces": "1", + "load-shed/priority": "NEVER", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "d1cad9b5b2be373605cc179c30a74dc3": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 7", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662622 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "15", + "meter/active-power": "-9.2", + "meter/current": "0.1", + "meter/exported-energy": "178558.9", + "meter/imported-energy": "66.5" + }, + "properties": { + "info/name": "Circuit 7", + "info/spaces": "7", + "load-shed/priority": "OFF_GRID", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "15adf6fa36ada1dba2184e9bcaa7fa44": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 12", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662773 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "15", + "meter/active-power": "-73.3", + "meter/current": "0.7", + "meter/exported-energy": "1065194.1", + "meter/imported-energy": "205.6" + }, + "properties": { + "info/name": "Circuit 12", + "info/spaces": "12", + "load-shed/priority": "NEVER", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "2835afc8a591cc382c4972a3538b824f": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Circuit 6", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.circuit", + "version": 1791071662468 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "20", + "meter/active-power": "-5.7", + "meter/current": "0.1", + "meter/exported-energy": "992065.0", + "meter/imported-energy": "309.1" + }, + "properties": { + "info/name": "Circuit 6", + "info/spaces": "6", + "load-shed/priority": "NEVER", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "nt-0000-panel": { + "description": { + "children": [ + "nt-0000-panel-lugs-up", + "nt-0000-panel-lugs-dn", + "nt-0000-panel-serial0001", + "nt-0000-panel-iq7plus-72-x-acm-us-y", + "1bc992eaeb96482b1d019edecfcc18c2", + "fbaae45daa7c145806eb9c5692fec660", + "36afaccafd19b2bfbcb6fcb5d6089399", + "e437d9b59344dd03bc2775fb238a5692", + "88416e5306e0a2d1e528908967f1f9fa", + "2e591e0d0cdc0e8bdbfdccbfc86d14ca", + "45ac025e77f416de0113e7ec0514925d", + "76321a44f69235b4702f74baf0d10aea", + "2835afc8a591cc382c4972a3538b824f", + "afd57a42ebec7cf6a5083315127698ec", + "1ac7fbc901ecb9d0c785dd3234b21a3c", + "9af59a336ebe2cd5c5ae5ff3d8424821", + "26ae0676844ed69346cccae68c2d516a", + "d1cad9b5b2be373605cc179c30a74dc3", + "e4f6668d42f85d5a0f3300c2a6747290", + "f45b824f00dc3829dd2ed6609d6cf17b", + "367c30fcffa7ac59fe428d7c426bd19d", + "420ee28ea3d7362dad8d341cc93e50b3", + "15adf6fa36ada1dba2184e9bcaa7fa44" + ], + "extensions": [], + "homie": "5.0", + "name": "SPAN Panel eBus Adapter", + "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": 1791071662785 + }, + "numeric_properties": { + "breaker/rating": "200", + "meter/voltage-a": "119.3", + "meter/voltage-b": "120.4", + "pcs/feed-import-limit": "160.0", + "pcs/import-limit": "160.0", + "pcs/requested-import-limit": "200.0", + "power-flows/battery": "0", + "power-flows/grid": "-2655", + "power-flows/pv": "0", + "power-flows/site": "2655.0", + "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" + }, + "properties": { + "door/state": "UNKNOWN", + "info/data-model-version": "1.0", + "info/firmware-version": "spanos3/r202633/02", + "info/hardware-version": "1.2", + "info/model": "MAIN_32", + "info/serial-number": "nt-0000-panel", + "info/vendor-name": "SPAN", + "pcs/active": "false", + "pcs/binding-constraint": "FSR", + "pcs/enabled": "false", + "pcs/feed-import-limit-active": "true", + "pcs/feed-import-limit-enablement": "ENABLED", + "pcs/requested-import-limit-active": "false", + "pcs/requested-import-limit-enablement": "UNCONFIGURED", + "shed-forecast/confidence": "HIGH", + "shed/asserted-islanding-state": "NONE", + "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 49, \"soc-threshold-release\": 51}}", + "status/cloud-connection": "UNKNOWN", + "status/ethernet": "true", + "status/postal-code": "00000", + "status/relay": "CLOSED", + "status/time-zone": "America/Los_Angeles", + "status/wifi": "false" + } + }, + "nt-0000-panel-iq7plus-72-x-acm-us-y": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "nt-0000-panel-iq7plus-72-x-acm-us-y", + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.pv", + "version": 1791071661955 + }, + "numeric_properties": { + "info/nominal-power": "4640" + }, + "properties": { + "info/model": "IQ7PLUS-72-x-ACM-US-y", + "info/vendor-name": "Enphase Energy" + } + }, + "nt-0000-panel-lugs-dn": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "nt-0000-panel-lugs-dn", + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.lugs", + "version": 1791071661426 + }, + "numeric_properties": { + "meter/active-power": "1937.2", + "meter/current-a": "27.4", + "meter/current-b": "4.6", + "meter/exported-energy": "11874656.7", + "meter/imported-energy": "34910219.6" + }, + "properties": { + "info/direction": "DOWNSTREAM" + } + }, + "nt-0000-panel-lugs-up": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "nt-0000-panel-lugs-up", + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.lugs", + "version": 1791071661410 + }, + "numeric_properties": { + "meter/active-power": "2490.9", + "meter/current-a": "27.4", + "meter/current-b": "4.6", + "meter/exported-energy": "3.3", + "meter/imported-energy": "35223342.5" + }, + "properties": { + "connection/fed-by-device-id": "nt-0000-other22", + "connection/fed-by-device-status": "OK", + "connection/fed-by-device-type": "energy.ebus.device.distribution-enclosure", + "info/direction": "UPSTREAM" + } + }, + "nt-0000-panel-serial0001": { + "description": { + "children": [ + "nt-0000-panel-serial0002" + ], + "extensions": [], + "homie": "5.0", + "name": "nt-0000-panel-serial0001", + "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": "nt-0000-panel", + "root": "nt-0000-panel", + "type": "energy.ebus.device.bess", + "version": 1791071661910 + }, + "numeric_properties": { + "info/nameplate-capacity": "81", + "meter/active-power": "0", + "soc/soc": "100.0", + "soc/soe": "81.0" + }, + "properties": { + "info/firmware-version": "26.26.4 68c2bc82", + "info/model": "Powerwall 2 AC", + "info/part-number": "1232100-00-E", + "info/serial-number": "serial0001", + "info/vendor-name": "Tesla", + "status/communication-state": "OK" + } + }, + "nt-0000-panel-serial0002": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "nt-0000-panel-serial0002", + "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": "nt-0000-panel-serial0001", + "root": "nt-0000-panel", + "type": "energy.ebus.device.mid", + "version": 1791071661924 + }, + "numeric_properties": {}, + "properties": { + "grid/grid-forming-entity": "GRID", + "grid/grid-state": "UNKNOWN", + "grid/islanding-state": "ON_GRID", + "info/serial-number": "serial0002", + "info/vendor-name": "Tesla" + } + } + }, + "metadata": { + "schema": "tree-v1" + } +} diff --git a/tests/fixtures/main32_r202639-tree-v1.README.md b/tests/fixtures/main32_r202639-tree-v1.README.md new file mode 100644 index 0000000..185fb24 --- /dev/null +++ b/tests/fixtures/main32_r202639-tree-v1.README.md @@ -0,0 +1,6 @@ +# main32_r202639-tree-v1.json + +The device tree a MAIN 32 panel publishes on firmware `spanos3/r202639/03`, as a `tree-v1` snapshot, with its device ids, serial numbers, postal code and Wi-Fi SSID masked. + +It is byte-identical to `tests/fixtures/main32_r202639-tree-v1.json` in [electrification-bus/distribution-enclosure-simulator](https://github.com/electrification-bus/distribution-enclosure-simulator) at tag `v0.9.0` (commit +`2dbddf7c507776a08e03a64825454972696d23df`), and is never regenerated here. `tests/adapter_fidelity/test_main32_fidelity.py` pins its SHA-256 and replays it through the schema-1 adapter. diff --git a/tests/fixtures/main32_r202639-tree-v1.json b/tests/fixtures/main32_r202639-tree-v1.json new file mode 100644 index 0000000..abfec95 --- /dev/null +++ b/tests/fixtures/main32_r202639-tree-v1.json @@ -0,0 +1,3292 @@ +{ + "devices": { + "390351d21af25d6409aba2b210928f7f": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "390351d21af25d6409aba2b210928f7f", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.circuit", + "version": 1791310949109 + }, + "numeric_properties": { + "breaker/poles": "2", + "breaker/rating": "30", + "meter/active-power": "-4.0", + "meter/current": "0.1", + "meter/exported-energy": "855286.5", + "meter/imported-energy": "3382.6", + "pcs/priority": "6" + }, + "properties": { + "info/name": "Clothes-Dryer", + "info/spaces": "2,4", + "load-shed/priority": "OFF_GRID", + "pcs/managed": "true", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "cb26f4260adebb7b71de48682618db0a": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "cb26f4260adebb7b71de48682618db0a", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.circuit", + "version": 1791310949816 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "20", + "meter/active-power": "-34.4", + "meter/current": "0.4", + "meter/exported-energy": "4176975.5", + "meter/imported-energy": "4928.9", + "pcs/priority": "13" + }, + "properties": { + "info/name": "Wine Refrigerator", + "info/spaces": "7", + "load-shed/priority": "SOC_THRESHOLD", + "pcs/managed": "true", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "9498a44c930374d2ea1e9508c3def44e": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "9498a44c930374d2ea1e9508c3def44e", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.circuit", + "version": 1791310950286 + }, + "numeric_properties": { + "breaker/poles": "2", + "breaker/rating": "50", + "meter/active-power": "-6.6", + "meter/current": "1.1", + "meter/exported-energy": "411437.5", + "meter/imported-energy": "7127.1", + "pcs/priority": "11" + }, + "properties": { + "info/name": "Cooktop/Stove", + "info/spaces": "18,20", + "load-shed/priority": "SOC_THRESHOLD", + "pcs/managed": "true", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "c00a7e2e5d468da627d084071177d865": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "c00a7e2e5d468da627d084071177d865", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.circuit", + "version": 1791310950120 + }, + "numeric_properties": { + "breaker/poles": "2", + "breaker/rating": "30", + "meter/active-power": "-12.6", + "meter/current": "0.1", + "meter/exported-energy": "872104.5", + "meter/imported-energy": "1431.4", + "pcs/priority": "1" + }, + "properties": { + "info/name": "Studio-HVAC", + "info/spaces": "17,19", + "load-shed/priority": "OFF_GRID", + "pcs/managed": "true", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "704b457f76e9451eb44fbc8287e67f9f": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "704b457f76e9451eb44fbc8287e67f9f", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.circuit", + "version": 1791310949200 + }, + "numeric_properties": { + "breaker/poles": "2", + "breaker/rating": "30", + "meter/active-power": "0.0", + "meter/current": "0.1", + "meter/exported-energy": "175305.8", + "meter/imported-energy": "51850.7", + "pcs/priority": "15" + }, + "properties": { + "info/name": "Future-240VAC-1", + "info/spaces": "21,23", + "load-shed/priority": "OFF_GRID", + "pcs/managed": "true", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "a6f39ecc6a8bf3453b9487dba9581b2c": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "a6f39ecc6a8bf3453b9487dba9581b2c", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.circuit", + "version": 1791310949278 + }, + "numeric_properties": { + "breaker/poles": "2", + "breaker/rating": "50", + "meter/active-power": "-4.4", + "meter/current": "0.5", + "meter/exported-energy": "1362032.0", + "meter/imported-energy": "10343.3", + "pcs/priority": "8" + }, + "properties": { + "info/name": "HVAC-Heat-Pump", + "info/spaces": "10,12", + "load-shed/priority": "OFF_GRID", + "pcs/managed": "true", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "266eb903e50116147fb212879e812cd7": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "266eb903e50116147fb212879e812cd7", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.circuit", + "version": 1791310950226 + }, + "numeric_properties": { + "breaker/poles": "1", + "breaker/rating": "20", + "meter/active-power": "0.0", + "meter/current": "0.1", + "meter/exported-energy": "686928.8", + "meter/imported-energy": "33914.1", + "pcs/priority": "14" + }, + "properties": { + "info/name": "Attic-Back-Outlet", + "info/spaces": "5", + "load-shed/priority": "SOC_THRESHOLD", + "pcs/managed": "true", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "f66769009923287a83be6740bd0bbfbd": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "f66769009923287a83be6740bd0bbfbd", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.circuit", + "version": 1791310949916 + }, + "numeric_properties": { + "breaker/poles": "2", + "breaker/rating": "45", + "meter/active-power": "0.0", + "meter/current": "0.1", + "meter/exported-energy": "28532.9", + "meter/imported-energy": "25667.0", + "pcs/priority": "4" + }, + "properties": { + "info/name": "HVAC-Heating-Coil", + "info/spaces": "6,8", + "load-shed/priority": "OFF_GRID", + "pcs/managed": "true", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "c0161381e50e069b9fd063a0ca1df808": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "c0161381e50e069b9fd063a0ca1df808", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.circuit", + "version": 1791310949627 + }, + "numeric_properties": { + "breaker/poles": "2", + "breaker/rating": "30", + "meter/active-power": "0.0", + "meter/current": "0.1", + "meter/exported-energy": "35474.5", + "meter/imported-energy": "45345.3", + "pcs/priority": "7" + }, + "properties": { + "info/name": "Future-240VAC-2", + "info/spaces": "25,27", + "load-shed/priority": "OFF_GRID", + "pcs/managed": "true", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "31f1d56b0926276e04c84360b1a31a6c": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "31f1d56b0926276e04c84360b1a31a6c", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.circuit", + "version": 1791310949154 + }, + "numeric_properties": { + "breaker/poles": "2", + "breaker/rating": "30", + "meter/active-power": "-67.9", + "meter/current": "0.4", + "meter/exported-energy": "1139553.7", + "meter/imported-energy": "2052.6", + "pcs/priority": "2" + }, + "properties": { + "info/name": "HVAC-Air-Handler", + "info/spaces": "13,15", + "load-shed/priority": "OFF_GRID", + "pcs/managed": "true", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "8a562bd10fb843842670080af2bbb983": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "8a562bd10fb843842670080af2bbb983", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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)" + } + }, + "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" + }, + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.circuit", + "version": 1791310950182 + }, + "numeric_properties": { + "breaker/poles": "2", + "meter/active-power": "1565.7", + "meter/current": "6.5", + "meter/exported-energy": "45779.2", + "meter/imported-energy": "17468812.0" + }, + "properties": { + "connection/feeds-device-id": "nt-9874-s7rxt-iq7plus-72-x-us", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.pv", + "info/name": "Commissioned PV System", + "info/spaces": "29,31", + "load-shed/priority": "NEVER", + "pcs/managed": "false", + "switch/relay": "CLOSED", + "switch/relay-controllable": "false", + "switch/relay-requester": "PCS" + } + }, + "aa47c5f799def01ce9ed945a4200b544": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "aa47c5f799def01ce9ed945a4200b544", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.circuit", + "version": 1791310949026 + }, + "numeric_properties": { + "breaker/poles": "2", + "breaker/rating": "50", + "meter/active-power": "-11.6", + "meter/current": "0.1", + "meter/exported-energy": "1084195.1", + "meter/imported-energy": "32973.4", + "pcs/priority": "12" + }, + "properties": { + "info/name": "Ovens", + "info/spaces": "14,16", + "load-shed/priority": "OFF_GRID", + "pcs/managed": "true", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "56def60d3bbd177fc8856cca8713c05a": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "56def60d3bbd177fc8856cca8713c05a", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.circuit", + "version": 1791310949522 + }, + "numeric_properties": { + "breaker/poles": "2", + "breaker/rating": "30", + "meter/active-power": "0.0", + "meter/current": "0.1", + "meter/exported-energy": "18919.9", + "meter/imported-energy": "4080.8", + "pcs/priority": "9" + }, + "properties": { + "info/name": "Shed-240VAC-Outlet", + "info/spaces": "26,28", + "load-shed/priority": "OFF_GRID", + "pcs/managed": "true", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "48c572b0051eaddaf85e1c26ef0f52be": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "48c572b0051eaddaf85e1c26ef0f52be", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.circuit", + "version": 1791310949376 + }, + "numeric_properties": { + "breaker/poles": "2", + "breaker/rating": "20", + "meter/active-power": "-3.8", + "meter/current": "0.2", + "meter/exported-energy": "1555161.9", + "meter/imported-energy": "2068.8", + "pcs/priority": "3" + }, + "properties": { + "info/name": "Espresso-Machine", + "info/spaces": "22,24", + "load-shed/priority": "SOC_THRESHOLD", + "pcs/managed": "true", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "f84ad966ee15cec0c359d151af8157e2": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "f84ad966ee15cec0c359d151af8157e2", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.circuit", + "version": 1791310949703 + }, + "numeric_properties": { + "breaker/poles": "2", + "breaker/rating": "30", + "meter/active-power": "0.0", + "meter/current": "0.1", + "meter/exported-energy": "717908.8", + "meter/imported-energy": "38742.9", + "pcs/priority": "5" + }, + "properties": { + "info/name": "Water-Heater", + "info/spaces": "9,11", + "load-shed/priority": "NEVER", + "pcs/managed": "true", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "caff3023f569495b2a36bf9354a8cd38": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "caff3023f569495b2a36bf9354a8cd38", + "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": "Physical panel position(s) the circuit occupies" + } + }, + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.circuit", + "version": 1791310950034 + }, + "numeric_properties": { + "breaker/poles": "2", + "breaker/rating": "20", + "meter/active-power": "-3.3", + "meter/current": "0.1", + "meter/exported-energy": "129126.5", + "meter/imported-energy": "11355.5", + "pcs/priority": "10" + }, + "properties": { + "info/name": "Kitchen-Outlets-1 & Outlets-2", + "info/spaces": "1,3", + "load-shed/priority": "NEVER", + "pcs/managed": "true", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + } + }, + "nt-9874-s7rxt": { + "description": { + "children": [ + "nt-9874-s7rxt-lugs-up", + "nt-9874-s7rxt-lugs-dn", + "nt-9874-s7rxt-tg681049356k0x", + "nt-9874-s7rxt-iq7plus-72-x-us", + "aa47c5f799def01ce9ed945a4200b544", + "390351d21af25d6409aba2b210928f7f", + "31f1d56b0926276e04c84360b1a31a6c", + "704b457f76e9451eb44fbc8287e67f9f", + "a6f39ecc6a8bf3453b9487dba9581b2c", + "48c572b0051eaddaf85e1c26ef0f52be", + "56def60d3bbd177fc8856cca8713c05a", + "c0161381e50e069b9fd063a0ca1df808", + "f84ad966ee15cec0c359d151af8157e2", + "cb26f4260adebb7b71de48682618db0a", + "f66769009923287a83be6740bd0bbfbd", + "caff3023f569495b2a36bf9354a8cd38", + "c00a7e2e5d468da627d084071177d865", + "8a562bd10fb843842670080af2bbb983", + "266eb903e50116147fb212879e812cd7", + "9498a44c930374d2ea1e9508c3def44e" + ], + "extensions": [], + "homie": "5.0", + "name": "SPAN Panel eBus Adapter", + "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": 1791310950308 + }, + "numeric_properties": { + "breaker/rating": "200", + "meter/voltage-a": "121.8", + "meter/voltage-b": "122.1", + "pcs/feed-import-limit": "160.0", + "pcs/import-limit": "160.0", + "pcs/operator-import-limit": "0.0", + "pcs/requested-import-limit": "200.0", + "power-flows/battery": "0", + "power-flows/grid": "-1257", + "power-flows/pv": "-1622", + "power-flows/site": "2879.0", + "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" + }, + "properties": { + "door/state": "UNKNOWN", + "info/data-model-version": "1.0", + "info/firmware-version": "spanos3/r202639/03", + "info/hardware-version": "1.2", + "info/model": "MAIN_32", + "info/serial-number": "nt-9874-s7rxt", + "info/vendor-name": "SPAN", + "pcs/active": "false", + "pcs/binding-constraint": "FSR", + "pcs/enabled": "true", + "pcs/feed-import-limit-active": "true", + "pcs/feed-import-limit-enablement": "ENABLED", + "pcs/operator-import-limit-active": "false", + "pcs/operator-import-limit-enablement": "UNCONFIGURED", + "pcs/requested-import-limit-active": "false", + "pcs/requested-import-limit-enablement": "UNCONFIGURED", + "shed-forecast/confidence": "HIGH", + "shed/asserted-islanding-state": "NONE", + "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 49, \"soc-threshold-release\": 51}}", + "status/cloud-connection": "UNKNOWN", + "status/ethernet": "true", + "status/postal-code": "00000", + "status/relay": "CLOSED", + "status/time-zone": "America/Los_Angeles", + "status/wifi": "false" + } + }, + "nt-9874-s7rxt-iq7plus-72-x-us": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "nt-9874-s7rxt-iq7plus-72-x-us", + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.pv", + "version": 1791310862583 + }, + "numeric_properties": { + "info/nominal-power": "4640" + }, + "properties": { + "info/model": "IQ7PLUS-72-x-US-&", + "info/vendor-name": "Enphase Energy" + } + }, + "nt-9874-s7rxt-lugs-dn": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "nt-9874-s7rxt-lugs-dn", + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.lugs", + "version": 1791310859527 + }, + "numeric_properties": { + "meter/active-power": "2700.6", + "meter/current-a": "26.6", + "meter/current-b": "5.7", + "meter/exported-energy": "9279931.9", + "meter/imported-energy": "9863069.9" + }, + "properties": { + "info/direction": "DOWNSTREAM" + } + }, + "nt-9874-s7rxt-lugs-up": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "nt-9874-s7rxt-lugs-up", + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.lugs", + "version": 1791310859473 + }, + "numeric_properties": { + "meter/active-power": "1283.7", + "meter/current-a": "26.6", + "meter/current-b": "5.7", + "meter/exported-energy": "4055664.2", + "meter/imported-energy": "27610149.5" + }, + "properties": { + "connection/fed-by-device-id": "nt-9874-s7rxt-tg681049356k0x", + "connection/fed-by-device-status": "OK", + "connection/fed-by-device-type": "energy.ebus.device.bess", + "info/direction": "UPSTREAM" + } + }, + "nt-9874-s7rxt-tg681049356k0x": { + "description": { + "children": [ + "nt-9874-s7rxt-tg957432104u3d-djc" + ], + "extensions": [], + "homie": "5.0", + "name": "nt-9874-s7rxt-tg681049356k0x", + "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": "nt-9874-s7rxt", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.bess", + "version": 1791310862520 + }, + "numeric_properties": { + "info/nameplate-capacity": "81", + "meter/active-power": "0.0", + "soc/soc": "100.0", + "soc/soe": "81.0" + }, + "properties": { + "info/firmware-version": "26.26.4 68c2bc82", + "info/model": "Powerwall 2 AC", + "info/part-number": "1232100-00-E", + "info/serial-number": "tg681049356k0x", + "info/vendor-name": "Tesla", + "status/communication-state": "OK" + } + }, + "nt-9874-s7rxt-tg957432104u3d-djc": { + "description": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "nt-9874-s7rxt-tg957432104u3d-djc", + "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": "nt-9874-s7rxt-tg681049356k0x", + "root": "nt-9874-s7rxt", + "type": "energy.ebus.device.mid", + "version": 1791310862535 + }, + "numeric_properties": {}, + "properties": { + "grid/grid-forming-entity": "GRID", + "grid/grid-state": "UNKNOWN", + "grid/islanding-state": "ON_GRID", + "info/serial-number": "tg957432104u3d-djc", + "info/vendor-name": "Tesla" + } + } + }, + "metadata": { + "schema": "tree-v1" + } +} From 0c34c37c91d7ce41820da211d201cea33d3d2007 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:38:11 -0700 Subject: [PATCH 2/8] fix(circuits): an UNKNOWN shed priority is not sheddable is_sheddable is now a shed priority (OFF_GRID or SOC_THRESHOLD) and a controllable relay. SPAN's r202633 changelog asks consumers to treat UNKNOWN as "not yet known" rather than as sheddable, which the previous priority != NEVER rule did not. --- packages/schema-1/CHANGELOG.md | 4 ++ .../src/span_panel_api_schema_1/circuits.py | 17 +++++-- .../src/span_panel_api_schema_1/const.py | 2 + tests/test_schema_one_circuits.py | 12 ++--- tests/test_schema_one_sheddable.py | 46 +++++++++++++++++++ 5 files changed, 71 insertions(+), 10 deletions(-) create mode 100644 tests/test_schema_one_sheddable.py diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index 3372e0b..eda71b0 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -24,6 +24,10 @@ Requires `span-panel-api` **3.7.0 or newer**, whose snapshot fields accept the u - **BREAKING: snapshots no longer include synthesised `unmapped_tab_N` entries for unoccupied breaker positions, which carried no measured data**, and `panel.build_unmapped_tabs` and `const.UNMAPPED_TAB_PREFIX` are gone with them. +### Fixed + +- **A circuit whose shed priority is `UNKNOWN` is no longer reported as sheddable.** + ## [1.2.2] Requires `span-panel-api` **3.6.2 or newer**, which waits on this parser's missing labels after a reconnect as well as on connect. 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 e2bda7a..7a10f79 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 @@ -10,9 +10,14 @@ ====================== =========================================================== ``always-on`` ``switch/relay-controllable``, inverted ``never-backup`` ``$settable`` on ``load-shed/priority``, inverted -``sheddable`` computed: ``priority != NEVER and relay-controllable`` +``sheddable`` computed: a shed priority and ``relay-controllable`` ====================== =========================================================== +A shed priority is ``OFF_GRID`` or ``SOC_THRESHOLD``. SPAN's r202633 changelog +derives sheddable as ``priority != NEVER`` and asks consumers to treat ``UNKNOWN`` +as "not yet known" rather than as sheddable, so the rule names the priorities the +panel sheds by instead of excluding the one it never sheds. + 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 @@ -23,7 +28,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from span_panel_api.models import SpanCircuitSnapshot from span_panel_api_schema_1.const import ( @@ -33,7 +38,8 @@ NODE_METER, NODE_PCS, NODE_SWITCH, - PRIORITY_NEVER, + PRIORITY_OFF_GRID, + PRIORITY_SOC_THRESHOLD, PROP_ACTIVE_POWER, PROP_CURRENT, PROP_EXPORTED_ENERGY, @@ -54,6 +60,9 @@ if TYPE_CHECKING: from ebus_sdk.homie import DiscoveredDevice +_SHED_PRIORITIES: Final = frozenset({PRIORITY_OFF_GRID, PRIORITY_SOC_THRESHOLD}) +"""The `load-shed/priority` values the panel sheds a circuit by; see the module docstring.""" + def _text(device: DiscoveredDevice, node: str, prop: str, default: str = "") -> str: value = device.get_property(node, prop) @@ -260,7 +269,7 @@ def build_circuit( # 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_sheddable=priority in _SHED_PRIORITIES and relay_controllable, is_never_backup=not priority_settable, device_type=device_type, relative_position=relative_position, 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 ffe4c1f..ba758bb 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 @@ -152,6 +152,8 @@ # -- Values ----------------------------------------------------------------- PRIORITY_NEVER = "NEVER" +PRIORITY_OFF_GRID = "OFF_GRID" +PRIORITY_SOC_THRESHOLD = "SOC_THRESHOLD" UNKNOWN = "UNKNOWN" CLOUD_CONNECTED = "CONNECTED" diff --git a/tests/test_schema_one_circuits.py b/tests/test_schema_one_circuits.py index 3886fda..2ad534a 100644 --- a/tests/test_schema_one_circuits.py +++ b/tests/test_schema_one_circuits.py @@ -142,8 +142,8 @@ def test_relay_controllable_defaults_to_controllable_when_absent(kitchen: Discov 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`.""" + """Retired with no replacement property, so derived: a shed priority + (`OFF_GRID` or `SOC_THRESHOLD`) and a controllable relay.""" # Kitchen: priority SOC_THRESHOLD (not NEVER) and controllable -> sheddable assert build_circuit(kitchen).is_sheddable is True # Solar: priority NEVER and not controllable -> not sheddable @@ -247,16 +247,16 @@ def test_every_catalogued_priority_is_carried_through_rather_than_repaired(kitch representativeness comes from the capture. Sheddability is asserted alongside because it is the derivation that would - hide a repaired value: `priority != NEVER and relay-controllable` makes every - member except `NEVER` sheddable, which is the answer the rule gives and not - one this reader should soften. + hide a repaired value: only a shed priority, `OFF_GRID` or `SOC_THRESHOLD`, + makes a controllable circuit sheddable, and `UNKNOWN` is "not yet known" + rather than a priority the panel sheds by. """ kitchen.update_property("load-shed", "priority", declared) circuit = build_circuit(kitchen) assert circuit.priority == declared - assert circuit.is_sheddable is (declared != "NEVER") + assert circuit.is_sheddable is (declared in {"OFF_GRID", "SOC_THRESHOLD"}) # --------------------------------------------------------------------------- diff --git a/tests/test_schema_one_sheddable.py b/tests/test_schema_one_sheddable.py new file mode 100644 index 0000000..eb992d1 --- /dev/null +++ b/tests/test_schema_one_sheddable.py @@ -0,0 +1,46 @@ +"""`is_sheddable` means the panel will shed it: a shed priority and a controllable relay. + +The public r202633 changelog says `UNKNOWN` means "not yet known", not sheddable. +""" + +from __future__ import annotations + +import json + +from ebus_sdk.homie import DiscoveredDevice +import pytest + +from reference_payloads.schema_one import device_from_topics, parent_child_tree +from span_panel_api_schema_1.circuits import build_circuit +from span_panel_api_schema_1.const import TYPE_CIRCUIT + + +def _circuit_device(*, priority: str, relay_controllable: bool) -> DiscoveredDevice: + """The reference tree's first circuit, with its shed priority and relay-controllable replaced.""" + tree = parent_child_tree() + circuit_id = next( + device_id for device_id, topics in sorted(tree.items()) if json.loads(topics["$description"])["type"] == TYPE_CIRCUIT + ) + topics = { + **tree[circuit_id], + "load-shed/priority": priority, + "switch/relay-controllable": "true" if relay_controllable else "false", + } + return device_from_topics(circuit_id, topics) + + +@pytest.mark.parametrize( + ("priority", "controllable", "sheddable"), + [ + ("OFF_GRID", True, True), + ("SOC_THRESHOLD", True, True), + ("NEVER", True, False), + ("UNKNOWN", True, False), + ("OFF_GRID", False, False), + ], +) +def test_sheddable_means_a_shed_priority_and_a_controllable_relay( + priority: str, controllable: bool, sheddable: bool +) -> None: + circuit = build_circuit(_circuit_device(priority=priority, relay_controllable=controllable)) + assert circuit.is_sheddable is sheddable From e672f1ede30ff42a1b96fc658217dacdf58fafeb Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:41:05 -0700 Subject: [PATCH 3/8] fix(snapshot): never surface a device id as a device's display name On r202639 every non-root device publishes its id as its $description.name, so while a battery formed the grid, grid_forming_device_name showed the battery's device id. device_display_name now keeps $description.name only where it differs from the id, falls back to vendor and model, or the model alone, and is otherwise None; it is the only producer of the snapshot's device names. The fidelity cells' check that no device is named by its own id now passes on both captures. --- packages/schema-1/CHANGELOG.md | 1 + .../src/span_panel_api_schema_1/panel.py | 29 +++++- .../src/span_panel_api_schema_1/snapshot.py | 8 +- src/span_panel_api/models.py | 6 +- .../adapter_fidelity/test_main32_fidelity.py | 1 - tests/test_schema_one_device_display_name.py | 91 +++++++++++++++++++ 6 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 tests/test_schema_one_device_display_name.py diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index eda71b0..81849b7 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -27,6 +27,7 @@ Requires `span-panel-api` **3.7.0 or newer**, whose snapshot fields accept the u ### Fixed - **A circuit whose shed priority is `UNKNOWN` is no longer reported as sheddable.** +- **The grid-forming device name falls back to vendor and model instead of showing a device id.** ## [1.2.2] 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 429eef8..2131a03 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 @@ -759,6 +759,29 @@ def resolve_dominant_power_source( return _POWER_SOURCE_BY_TYPE.get(device_types.get(forming, ""), UNKNOWN) +def device_display_name(device: DiscoveredDevice) -> str | None: + """A device's readable name, or `None` when nothing it publishes names it. + + Homie's `$description.name` (`Battery`, `Solar`, `SPAN Drive - Garage`) where it + is set and is not the device's own id. Real r202639 panels publish each non-root + device's id there, and an id shown as a name is the outcome + `resolve_grid_forming_device_name` exists to avoid. Otherwise the name comes from + the device's `info`: vendor and model together (`Tesla Powerwall 2 AC`), or the + model alone. A vendor alone names a maker rather than a device, so it gives `None`. + + An empty value names nothing, exactly as an unpublished one does. + """ + description: dict[str, object] = device.description or {} + name = description.get("name") + if name and str(name) != device.device_id: + return str(name) + vendor = text(device, NODE_INFO, PROP_VENDOR_NAME) + model = text(device, NODE_INFO, PROP_MODEL) + if vendor and model: + return f"{vendor} {model}" + return model or None + + def resolve_grid_forming_device_name( mid: DiscoveredDevice | None, device_names: Mapping[str, str], @@ -767,9 +790,9 @@ def resolve_grid_forming_device_name( 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. + opaque string is worse than no string. `device_names` holds each device's + `device_display_name`, 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 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 4579528..fafebc9 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 @@ -42,6 +42,7 @@ from span_panel_api_schema_1.panel import ( PanelFields, build_pcs, + device_display_name, find_lugs, panel_size_from_model, resolve_dominant_power_source, @@ -174,12 +175,7 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice]) -> # 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) + device_names = {device.device_id: name for device in children if (name := device_display_name(device)) is not None} inverters = [device for device in children if device_type(device) == TYPE_INVERTER] islanding = resolve_islanding_state(roles.mid, panel) diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 8bdf89f..07dc41f 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -214,8 +214,10 @@ class SpanMidSnapshot: 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. + device's own `$description.name` (`Battery`, `Solar`, `SPAN Drive - Garage`) where + that is not the id itself, else its vendor and model (`Tesla Powerwall 2 AC`), which + is the part a person can read; `None` when it publishes nothing that names it. The + literal stays available beside it. """ diff --git a/tests/adapter_fidelity/test_main32_fidelity.py b/tests/adapter_fidelity/test_main32_fidelity.py index 1a39adf..e25dfab 100644 --- a/tests/adapter_fidelity/test_main32_fidelity.py +++ b/tests/adapter_fidelity/test_main32_fidelity.py @@ -96,7 +96,6 @@ def test_no_leaf_is_fabricated_outside_any_row(cell: str) -> None: assert _report(cell).unowned_leaves() == frozenset() -@pytest.mark.xfail(strict=True, reason="fixed later in this PR") @pytest.mark.parametrize("cell", sorted(CELLS)) def test_no_device_is_named_by_its_own_id(cell: str) -> None: """While a device forms the grid, the name shown for it is never its device id. diff --git a/tests/test_schema_one_device_display_name.py b/tests/test_schema_one_device_display_name.py new file mode 100644 index 0000000..c8fd796 --- /dev/null +++ b/tests/test_schema_one_device_display_name.py @@ -0,0 +1,91 @@ +"""A device's display name is never its own id. + +Real r202639 panels publish each non-root device's id as its `$description.name` +(the public MAIN 32 capture shows it for the battery, MID, PV, lugs and circuits). +Surfacing that id as a name is the outcome the attribute's docstring calls worse +than no name. +""" + +from __future__ import annotations + +import json + +from ebus_sdk.homie import DiscoveredDevice + +from reference_payloads.schema_one import device_from_topics, parent_child_tree +from span_panel_api.models import SpanPanelSnapshot +from span_panel_api_schema_1.const import TYPE_BESS, TYPE_MID +from span_panel_api_schema_1.panel import device_display_name +from span_panel_api_schema_1.snapshot import build_snapshot + +_TREE = parent_child_tree() + + +def _device_id(device_type: str) -> str: + return next( + device_id for device_id, topics in _TREE.items() if json.loads(topics["$description"])["type"] == device_type + ) + + +BESS_ID = _device_id(TYPE_BESS) +MID_ID = _device_id(TYPE_MID) +PANEL_ID = next(device_id for device_id, topics in _TREE.items() if not json.loads(topics["$description"]).get("parent")) + + +def _bess_topics(*, name: str, vendor: str | None, model: str | None) -> dict[str, str]: + """The reference BESS's retained topics, named and identified as given; `None` leaves a property unvalued.""" + description = json.loads(_TREE[BESS_ID]["$description"]) + description["name"] = name + topics = {topic: value for topic, value in _TREE[BESS_ID].items() if topic not in ("info/vendor-name", "info/model")} + topics["$description"] = json.dumps(description) + if vendor is not None: + topics["info/vendor-name"] = vendor + if model is not None: + topics["info/model"] = model + return topics + + +def _device(*, name: str, vendor: str | None, model: str | None) -> DiscoveredDevice: + return device_from_topics(BESS_ID, _bess_topics(name=name, vendor=vendor, model=model)) + + +def _reference_snapshot(*, bess_name_is_its_id: bool, grid_forming: str) -> SpanPanelSnapshot: + """The reference tree with the MID naming `grid_forming`, and the BESS identified as the public MAIN 32 capture shows it.""" + tree = {device_id: dict(topics) for device_id, topics in _TREE.items()} + tree[BESS_ID] = _bess_topics(name=BESS_ID if bess_name_is_its_id else "Battery", vendor="Tesla", model="Powerwall 2 AC") + tree[MID_ID]["grid/grid-forming-entity"] = grid_forming + panel = device_from_topics(PANEL_ID, tree[PANEL_ID]) + children = [device_from_topics(device_id, topics) for device_id, topics in tree.items() if device_id != PANEL_ID] + return build_snapshot(panel, children) + + +def test_a_name_equal_to_the_id_falls_back_to_vendor_and_model() -> None: + device = _device(name=BESS_ID, vendor="Tesla", model="Powerwall 2 AC") + assert device_display_name(device) == "Tesla Powerwall 2 AC" + + +def test_model_alone_names_the_device() -> None: + assert device_display_name(_device(name=BESS_ID, vendor=None, model="Powerwall 2 AC")) == "Powerwall 2 AC" + + +def test_a_vendor_alone_does_not_name_a_device() -> None: + assert device_display_name(_device(name=BESS_ID, vendor="Tesla", model=None)) is None + + +def test_an_empty_value_is_no_value() -> None: + """An empty name, vendor or model names nothing, exactly as an unpublished one does.""" + assert device_display_name(_device(name="", vendor="", model="Powerwall 2 AC")) == "Powerwall 2 AC" + assert device_display_name(_device(name=BESS_ID, vendor="Tesla", model="")) is None + + +def test_a_real_name_is_kept() -> None: + assert device_display_name(_device(name="Battery", vendor="Tesla", model="Powerwall 2 AC")) == "Battery" + + +def test_the_grid_forming_attribute_names_the_battery_not_its_id() -> None: + snapshot = _reference_snapshot(bess_name_is_its_id=True, grid_forming=BESS_ID) + assert snapshot.mid is not None + assert snapshot.mid.grid_forming_device_name == "Tesla Powerwall 2 AC" + grid = _reference_snapshot(bess_name_is_its_id=True, grid_forming="GRID") + assert grid.mid is not None + assert grid.mid.grid_forming_device_name is None From 790d9ee9a8fb0a6b68ccbe311525b6c874fb7c03 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:09:42 -0700 Subject: [PATCH 4/8] test(fidelity): ship the upstream license beside the vendored MAIN 32 captures The two captures are distributed under the eBus panel simulator's MIT license, and the sdist carries tests/, so that repository's LICENSE at the same v0.9.0 tag is copied beside them byte for byte as distribution-enclosure-simulator.LICENSE, cited in both READMEs and pinned by SHA-256 with the captures. The hooks that rewrite whitespace and line endings now skip the captures and the license. The READMEs no longer say the Wi-Fi SSID is masked: both captures declare status/wifi-ssid and publish no value for it. --- .pre-commit-config.yaml | 17 ++++++++------- .../adapter_fidelity/test_main32_fidelity.py | 12 +++++++++++ .../distribution-enclosure-simulator.LICENSE | 21 +++++++++++++++++++ tests/fixtures/main32-tree-v1.README.md | 7 +++++-- .../fixtures/main32_r202639-tree-v1.README.md | 6 ++++-- 5 files changed, 52 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/distribution-enclosure-simulator.LICENSE diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7c7f8de..6d819e5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,14 +4,17 @@ repos: 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. + # verified by byte comparison in tests/test_schema_one_conformance.py, and + # the two MAIN 32 captures in tests/fixtures/ are byte copies of upstream with + # that repository's LICENSE beside them, pinned by SHA-256 in + # tests/adapter_fidelity/test_main32_fidelity.py. Any hook that rewrites a file + # must skip them: 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/.*|^packages/schema-1/spec/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' + exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|^tests/fixtures/(main32(_r202639)?-tree-v1\.json|distribution-enclosure-simulator\.LICENSE)$|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' - id: end-of-file-fixer - exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' + exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|^tests/fixtures/(main32(_r202639)?-tree-v1\.json|distribution-enclosure-simulator\.LICENSE)$|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' - id: check-yaml exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' - id: check-toml @@ -25,7 +28,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/.*|^packages/schema-1/spec/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' + exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|^tests/fixtures/(main32(_r202639)?-tree-v1\.json|distribution-enclosure-simulator\.LICENSE)$|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' # Ruff for formatting and linting - repo: https://github.com/astral-sh/ruff-pre-commit diff --git a/tests/adapter_fidelity/test_main32_fidelity.py b/tests/adapter_fidelity/test_main32_fidelity.py index e25dfab..6f957bd 100644 --- a/tests/adapter_fidelity/test_main32_fidelity.py +++ b/tests/adapter_fidelity/test_main32_fidelity.py @@ -51,6 +51,12 @@ class _Cell: } +_UPSTREAM_NOTICES = { + "distribution-enclosure-simulator.LICENSE": "6174860cd3289ce0d1a39a186e9bbf907504dbac4ef0e00ab0d490d4a0fc2501", +} +"""Upstream files in `FIXTURES` copied beside the captures, byte for byte from the same tag, and the SHA-256 of each.""" + + @cache def _report(cell: str) -> FidelityReport: return run_cell(CELLS[cell].capture, MAIN32_RULES) @@ -112,6 +118,12 @@ def test_the_capture_is_the_published_one(cell: str) -> None: assert hashlib.sha256(CELLS[cell].capture.read_bytes()).hexdigest() == CELLS[cell].sha256 +@pytest.mark.parametrize("notice", sorted(_UPSTREAM_NOTICES)) +def test_the_upstream_license_is_the_published_one(notice: str) -> None: + """The captures are distributed under the license they were published with, unedited.""" + assert hashlib.sha256((FIXTURES / notice).read_bytes()).hexdigest() == _UPSTREAM_NOTICES[notice] + + def test_every_intentional_drop_and_derivation_cites_code() -> None: assert set(MAIN32_RULES.drops) == set(DropReason) assert all(reason.strip() for reason in MAIN32_RULES.justifications.values()) diff --git a/tests/fixtures/distribution-enclosure-simulator.LICENSE b/tests/fixtures/distribution-enclosure-simulator.LICENSE new file mode 100644 index 0000000..1f40454 --- /dev/null +++ b/tests/fixtures/distribution-enclosure-simulator.LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Bill Flood, Clark Communications Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/fixtures/main32-tree-v1.README.md b/tests/fixtures/main32-tree-v1.README.md index e4a48aa..6987ca3 100644 --- a/tests/fixtures/main32-tree-v1.README.md +++ b/tests/fixtures/main32-tree-v1.README.md @@ -1,6 +1,9 @@ # main32-tree-v1.json -The device tree a MAIN 32 panel publishes on firmware `spanos3/r202633/02`, as a `tree-v1` snapshot, with its device ids, serial numbers, postal code and Wi-Fi SSID masked and its circuit names made generic. +The device tree a MAIN 32 panel publishes on firmware `spanos3/r202633/02`, as a `tree-v1` snapshot, with its device ids, serial numbers and postal code masked and its circuit names made generic. It declares `status/wifi-ssid` and publishes no value for +it. It is byte-identical to `tests/fixtures/main32-tree-v1.json` in [electrification-bus/distribution-enclosure-simulator](https://github.com/electrification-bus/distribution-enclosure-simulator) at tag `v0.9.0` (commit -`2dbddf7c507776a08e03a64825454972696d23df`), and is never regenerated here. `tests/adapter_fidelity/test_main32_fidelity.py` pins its SHA-256 and replays it through the schema-1 adapter. +`2dbddf7c507776a08e03a64825454972696d23df`), and is distributed under that repository's MIT license. `distribution-enclosure-simulator.LICENSE` beside it is that repository's `LICENSE` at the same tag, copied byte for byte. + +It is never regenerated here. `tests/adapter_fidelity/test_main32_fidelity.py` pins the SHA-256 of the capture and of the license, and replays the capture through the schema-1 adapter. diff --git a/tests/fixtures/main32_r202639-tree-v1.README.md b/tests/fixtures/main32_r202639-tree-v1.README.md index 185fb24..41d9297 100644 --- a/tests/fixtures/main32_r202639-tree-v1.README.md +++ b/tests/fixtures/main32_r202639-tree-v1.README.md @@ -1,6 +1,8 @@ # main32_r202639-tree-v1.json -The device tree a MAIN 32 panel publishes on firmware `spanos3/r202639/03`, as a `tree-v1` snapshot, with its device ids, serial numbers, postal code and Wi-Fi SSID masked. +The device tree a MAIN 32 panel publishes on firmware `spanos3/r202639/03`, as a `tree-v1` snapshot, with its device ids, serial numbers and postal code masked. It declares `status/wifi-ssid` and publishes no value for it. It is byte-identical to `tests/fixtures/main32_r202639-tree-v1.json` in [electrification-bus/distribution-enclosure-simulator](https://github.com/electrification-bus/distribution-enclosure-simulator) at tag `v0.9.0` (commit -`2dbddf7c507776a08e03a64825454972696d23df`), and is never regenerated here. `tests/adapter_fidelity/test_main32_fidelity.py` pins its SHA-256 and replays it through the schema-1 adapter. +`2dbddf7c507776a08e03a64825454972696d23df`), and is distributed under that repository's MIT license. `distribution-enclosure-simulator.LICENSE` beside it is that repository's `LICENSE` at the same tag, copied byte for byte. + +It is never regenerated here. `tests/adapter_fidelity/test_main32_fidelity.py` pins the SHA-256 of the capture and of the license, and replays the capture through the schema-1 adapter. From dc90b7aaa6b992b5ba15763a5fec6b38d3955a13 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:13:39 -0700 Subject: [PATCH 5/8] test(fidelity): check every value a property sets, perturbed ones included Only the captured value was held to its transform, so a property the capture holds at zero, which reads the same under identity and negation, could carry a wrong sign unseen; the battery reads 0 W on both MAIN 32 captures, so its firmware-dependent frame was never checked. Every value the perturbation publishes is now held to the same transform. Derivations simple enough to state (two poles or more, not relay-controllable, a CONNECTED cloud link, an OK link, the listed spaces) are checked against their rule rather than accepted whatever they hold, and the MID's serial is a literal. The sign self-test now flips a circuit drawing power with 0.0 - x, so the sign itself fails rather than a -0.0, and new self-tests flip the battery's frame while it reads 0 W and invert is_240v. --- tests/adapter_fidelity/harness.py | 86 ++++++++++++++++---------- tests/adapter_fidelity/rules.py | 65 +++++++++++++++++-- tests/adapter_fidelity/test_harness.py | 66 +++++++++++++++++--- 3 files changed, 173 insertions(+), 44 deletions(-) diff --git a/tests/adapter_fidelity/harness.py b/tests/adapter_fidelity/harness.py index 2bf9c7d..ae60bcf 100644 --- a/tests/adapter_fidelity/harness.py +++ b/tests/adapter_fidelity/harness.py @@ -21,8 +21,10 @@ unvalued one is perturbed on a replay of its own, since nothing on the wire returns a property to unvalued. The session must end equal to the baseline. 4. *Classify* each declared property instance by its effect into a `Fate`. -5. *Check values.* Every leaf a valued property moves must equal the wire value - under the transform `CellRules` documents for it. +5. *Check values.* Every leaf a property moves, at its captured value and at each + perturbed one, must follow from that wire value under the transform `CellRules` + documents for it. The perturbed values matter: a captured zero reads the same + under identity and negation. 6. *Audit leaves.* Every non-empty leaf that no valued property moves must be a derivation `CellRules` documents; anything else is fabricated. @@ -33,7 +35,7 @@ from __future__ import annotations from collections import Counter -from collections.abc import Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass, field, fields, is_dataclass from enum import StrEnum import json @@ -117,6 +119,8 @@ class Transform: cite: str earlier: tuple[int, TransformKind] | None = None """``(build, kind)``: before release build ``build`` the wire carries ``kind`` instead.""" + holds: Callable[[str, object], bool] | None = None + """For a derivation simple enough to state: whether ``value`` follows from the wire value ``raw``.""" def kind_for(self, firmware: str) -> TransformKind: """The transform for a panel on ``firmware``. @@ -522,6 +526,8 @@ class _Instance: addressed: bool extra: bool effect: set[str] = field(default_factory=set) + perturbed: list[tuple[str, dict[str, object]]] = field(default_factory=list) + """Each value published, and what every leaf it moved then held.""" fate: Fate = Fate.SILENT deferred: bool = False @@ -583,7 +589,10 @@ def _measure(capture: _Capture, instances: list[_Instance], baseline: Mapping[st target = session if instance.raw is not None else _replay(capture) for candidate in _candidates(instance.declaration, instance.raw, property_id, references): target.handle_message(topic, candidate) - instance.effect |= _moved(baseline, _flatten(target)) + leaves = _flatten(target) + moved = _moved(baseline, leaves) + instance.effect |= moved + instance.perturbed.append((candidate, {leaf: leaves[leaf] for leaf in moved if leaf in leaves})) if instance.raw is not None: session.handle_message(topic, instance.raw) unrestored = _moved(baseline, _flatten(session)) @@ -672,7 +681,7 @@ def _observe(raw: str, value: object) -> str: def _compatible(observed: str, expected: TransformKind, raw: str) -> bool: - if expected == TransformKind.DERIVED or observed == expected: + if observed == expected: return True # A zero reads the same under identity and negation. if expected == TransformKind.NEGATE and observed == TransformKind.IDENTITY and _number(raw) == 0.0: @@ -688,38 +697,53 @@ def _check_values( failures: dict[str, set[RowKey]], notes: dict[str, str], ) -> set[str]: + """Every value a property's own wire value set, held to the transform documented for it. + + The captured value and every perturbed one alike. A captured zero reads the + same under identity and negation, so a sign defect on a property the capture + holds at zero shows only in the values the perturbation published. + """ mismatched: set[str] = set() for instance in instances: - if instance.fate not in (Fate.MAPPED, Fate.EXTENSION) or instance.raw is None: - # An unvalued source's leaves are the leaf audit's to judge. + if instance.fate not in (Fate.MAPPED, Fate.EXTENSION): continue - raw = instance.raw - for leaf in sorted(instance.effect): - if leaf.startswith(("ctl.", "ready.", "adopted[")) or leaf not in baseline: - continue - if _instance(leaf) == raw: - continue # this property is the instance's snapshot key - value = baseline[leaf] - problem: str | None = None - if leaf.startswith("ext["): - if value != raw: - problem = f"extension value {value!r} is not the wire value {raw!r}" - else: - transform = rules.transform_for(instance.key, family(leaf)) - if transform is None: - problem = f"no documented transform from {instance.key.node_property}" - else: - expected = transform.kind_for(capture.firmware) - observed = _observe(raw, value) - if not _compatible(observed, expected, raw): - problem = f"{raw!r} -> {value!r} is {observed}, documented {expected} ({transform.cite})" - if problem is not None: - mismatched.add(leaf) - failures.setdefault(leaf, set()).add(instance.key) - notes[leaf] = f"{instance.key.role} {instance.key.node_property}: {problem}" + observations: list[tuple[str, Mapping[str, object]]] = list(instance.perturbed) + if instance.raw is not None: + # An unvalued source's captured leaves are the leaf audit's to judge. + observations.insert(0, (instance.raw, {leaf: baseline[leaf] for leaf in instance.effect if leaf in baseline})) + for raw, values in observations: + # A property can be the snapshot key of the leaves it moves, as a charger's + # serial is; those leaves are keyed by it, not valued by it. + keys = {raw} if instance.raw is None else {raw, instance.raw} + for leaf, value in sorted(values.items()): + if leaf.startswith(("ctl.", "ready.", "adopted[")) or _instance(leaf) in keys: + continue + problem = _transform_problem(instance.key, leaf, raw, value, rules, capture.firmware) + if problem is not None: + mismatched.add(leaf) + failures.setdefault(leaf, set()).add(instance.key) + notes[leaf] = f"{instance.key.role} {instance.key.node_property}: {problem}" return mismatched +def _transform_problem(key: RowKey, leaf: str, raw: str, value: object, rules: CellRules, firmware: str) -> str | None: + """What is wrong with ``value`` as what ``raw`` on ``key`` set ``leaf`` to, or `None`.""" + if leaf.startswith("ext["): + return None if value == raw else f"extension value {value!r} is not the wire value {raw!r}" + transform = rules.transform_for(key, family(leaf)) + if transform is None: + return f"no documented transform from {key.node_property}" + expected = transform.kind_for(firmware) + if expected == TransformKind.DERIVED: + if transform.holds is None or transform.holds(raw, value): + return None + return f"{raw!r} -> {value!r} breaks the documented derivation ({transform.cite})" + observed = _observe(raw, value) + if _compatible(observed, expected, raw): + return None + return f"{raw!r} -> {value!r} is {observed}, documented {expected} ({transform.cite})" + + # --- the leaf audit --------------------------------------------------------- diff --git a/tests/adapter_fidelity/rules.py b/tests/adapter_fidelity/rules.py index 119f5fd..6a49bc0 100644 --- a/tests/adapter_fidelity/rules.py +++ b/tests/adapter_fidelity/rules.py @@ -14,6 +14,7 @@ from __future__ import annotations +from collections.abc import Callable from typing import Final from .harness import ANY_LEAF, CellRules, Derivation, DropReason, RowKey, Transform, TransformKind @@ -29,11 +30,53 @@ """Mirrors `devices.BESS_METER_DISCHARGE_POSITIVE_FROM_BUILD`, restated so the rule is checked rather than borrowed.""" +def _number(raw: str) -> float | None: + try: + return float(raw) + except ValueError: + return None + + +def _two_poles_or_more(raw: str, value: object) -> bool: + poles = _number(raw) + return value is (poles is not None and poles >= 2) + + +def _not_true(raw: str, value: object) -> bool: + return value is (raw.strip().lower() != "true") + + +def _is_connected_cloud(raw: str, value: object) -> bool: + return value is (raw == "CONNECTED") + + +def _is_ok_link(raw: str, value: object) -> bool: + return value is (raw == "OK") + + +def _listed_spaces(raw: str, value: object) -> bool: + """The integers in a comma-separated list, in order; a part that is not one is skipped.""" + spaces: list[int] = [] + for part in raw.split(","): + try: + spaces.append(int(part.strip())) + except ValueError: + continue + return value == tuple(spaces) + + def _transforms() -> dict[RowKey, dict[str, Transform]]: table: dict[RowKey, dict[str, Transform]] = {} - def add(role: str, node_property: str, family: str, kind: TransformKind, cite: str) -> None: - table.setdefault(RowKey(role, node_property), {})[family] = Transform(kind, cite) + def add( + role: str, + node_property: str, + family: str, + kind: TransformKind, + cite: str, + holds: Callable[[str, object], bool] | None = None, + ) -> None: + table.setdefault(RowKey(role, node_property), {})[family] = Transform(kind, cite, holds=holds) # --- panel ------------------------------------------------------------- panel_fields = "panel.PanelFields" @@ -62,6 +105,7 @@ def add(role: str, node_property: str, family: str, kind: TransformKind, cite: s "panel.wwan_link", _DERIVED, "panel.PanelFields: the cloud link is CONNECTED, as flat reported it", + _is_connected_cloud, ) add("panel", "meter/voltage-a", "panel.l1_voltage", _IDENTITY, panel_fields) add("panel", "meter/voltage-b", "panel.l2_voltage", _IDENTITY, panel_fields) @@ -144,13 +188,20 @@ def add(role: str, node_property: str, family: str, kind: TransformKind, cite: s add("circuit", "meter/exported-energy", "circuit.consumed_energy_wh", _IDENTITY, f"{circuit}: the accumulators swap") add("circuit", "meter/current", "circuit.current_a", _IDENTITY, circuit) add("circuit", "breaker/rating", "circuit.breaker_rating_a", _IDENTITY, circuit) - add("circuit", "breaker/poles", "circuit.is_240v", _DERIVED, f"{circuit}: two poles or more") + add("circuit", "breaker/poles", "circuit.is_240v", _DERIVED, f"{circuit}: two poles or more", _two_poles_or_more) add("circuit", "info/name", "circuit.name", _LITERAL, circuit) - add("circuit", "info/spaces", "circuit.tabs", _DERIVED, "circuits._tabs parses the list of spaces") + add("circuit", "info/spaces", "circuit.tabs", _DERIVED, "circuits._tabs parses the list of spaces", _listed_spaces) add("circuit", "switch/relay", "circuit.relay_state", _LITERAL, circuit) add("circuit", "switch/relay-requester", "circuit.relay_requester", _LITERAL, circuit) add("circuit", "switch/relay-controllable", "circuit.is_user_controllable", _BOOL, circuit) - add("circuit", "switch/relay-controllable", "circuit.always_on", _DERIVED, f"{circuit}: not relay-controllable") + add( + "circuit", + "switch/relay-controllable", + "circuit.always_on", + _DERIVED, + f"{circuit}: not relay-controllable", + _not_true, + ) add( "circuit", "switch/relay-controllable", @@ -182,6 +233,7 @@ def add(role: str, node_property: str, family: str, kind: TransformKind, cite: s f"{der}.connected", _DERIVED, "devices.feed_connection_statuses and devices._connected: OK is a working link", + _is_ok_link, ) # --- lugs -------------------------------------------------------------- @@ -223,6 +275,7 @@ def add(role: str, node_property: str, family: str, kind: TransformKind, cite: s "battery.connected", _DERIVED, "devices.connection_status_for and devices._connected: OK is a working link", + _is_ok_link, ) add( role, @@ -255,7 +308,7 @@ def add(role: str, node_property: str, family: str, kind: TransformKind, cite: s add("mid", "info/vendor-name", "mid.vendor_name", _LITERAL, mid) add("mid", "info/model", "mid.model", _LITERAL, mid) add("mid", "info/serial-number", "mid.serial_number", _LITERAL, mid) - add("mid", "info/serial-number", "mid.node_id", _DERIVED, f"{mid}: the serial, else the device id") + add("mid", "info/serial-number", "mid.node_id", _LITERAL, f"{mid}: the serial where one is published") add("mid", "info/firmware-version", "mid.software_version", _LITERAL, mid) add("mid", "info/hardware-version", "mid.hardware_version", _LITERAL, mid) add("mid", "grid/islanding-state", "mid.islanding_state", _LITERAL, mid) diff --git a/tests/adapter_fidelity/test_harness.py b/tests/adapter_fidelity/test_harness.py index ab610ab..b540161 100644 --- a/tests/adapter_fidelity/test_harness.py +++ b/tests/adapter_fidelity/test_harness.py @@ -3,8 +3,8 @@ A gate that cannot fail proves nothing. So each kind of failure is planted in the adapter's snapshot, over a small cut of the public r202639 capture, and the harness must name it: a value with no wire behind it, one behind only unvalued -properties, one under the wrong transform, and a published property that reaches -nothing. +properties, a wrong sign (on a captured zero too), a derivation that breaks its +rule, and a published property that reaches nothing. """ from __future__ import annotations @@ -36,9 +36,19 @@ def _mapping(value: object) -> dict[str, object]: return {str(key): item for key, item in value.items()} +def _power(record: Mapping[str, object]) -> float | None: + raw = _mapping(record.get("numeric_properties", {})).get("meter/active-power") + return float(raw) if isinstance(raw, str) else None + + @pytest.fixture def small_capture(tmp_path: Path) -> Path: - """The capture with one circuit, and no PV: the panel, its lugs, its battery and MID, and that circuit.""" + """The capture cut to the panel, its lugs, its battery and MID, and one circuit drawing power. + + A circuit at a non-zero reading, so that a wrong sign shows in the captured + value itself; the battery reads 0 W, so a wrong sign there shows only in the + values the perturbation publishes. + """ tree = _mapping(json.loads(CAPTURE.read_text(encoding="utf-8"))) devices = _mapping(tree["devices"]) circuits = sorted( @@ -46,7 +56,8 @@ def small_capture(tmp_path: Path) -> Path: for device_id, entry in devices.items() if _mapping(_mapping(entry)["description"]).get("type") == TYPE_CIRCUIT ) - dropped = set(circuits[1:]) | { + drawing = next(device_id for device_id in circuits if _power(_mapping(devices[device_id])) not in (None, 0.0)) + dropped = {device_id for device_id in circuits if device_id != drawing} | { device_id for device_id, entry in devices.items() if _mapping(_mapping(entry)["description"]).get("type") == TYPE_PV } kept: dict[str, object] = {} @@ -107,13 +118,16 @@ def model_or_placeholder(snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: assert report.unowned_leaves() == frozenset() -def test_a_value_under_the_wrong_transform_is_mismatched(small_capture: Path, plant: Callable[[Plant], None]) -> None: - """Circuit power left in the enclosure frame, where a load reads negative.""" +def test_a_wrong_sign_is_mismatched(small_capture: Path, plant: Callable[[Plant], None]) -> None: + """Circuit power left in the enclosure frame, where a load reads negative. + + Flipped as `0.0 - x`, which never yields -0.0, so the sign itself is what fails. + """ def unnegated(snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: circuits = { circuit_id: replace( - circuit, instant_power_w=None if circuit.instant_power_w is None else -circuit.instant_power_w + circuit, instant_power_w=None if circuit.instant_power_w is None else 0.0 - circuit.instant_power_w ) for circuit_id, circuit in snapshot.circuits.items() } @@ -125,6 +139,44 @@ def unnegated(snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: assert report.failing_rows() == {RowKey("circuit", "meter/active-power")} +def test_a_wrong_sign_on_a_reading_captured_at_zero_is_mismatched( + small_capture: Path, plant: Callable[[Plant], None] +) -> None: + """The battery's frame on r202639, where the capture's battery reads 0 W. + + A captured zero reads the same under either sign, so only the values the + perturbation publishes can show the frame is wrong. + """ + plant( + lambda snapshot: replace( + snapshot, + battery=replace( + snapshot.battery, + power_w=None if snapshot.battery.power_w is None else 0.0 - snapshot.battery.power_w, + ), + ) + ) + report = run_cell(small_capture, MAIN32_RULES) + assert report.mismatched == ("battery.power_w",) + assert report.failing_rows() == {RowKey("bess", "meter/active-power")} + + +def test_a_derivation_that_breaks_its_rule_is_mismatched(small_capture: Path, plant: Callable[[Plant], None]) -> None: + """A breaker read as 240 V exactly when it has fewer than two poles.""" + plant( + lambda snapshot: replace( + snapshot, + circuits={ + circuit_id: replace(circuit, is_240v=not circuit.is_240v) + for circuit_id, circuit in snapshot.circuits.items() + }, + ) + ) + report = run_cell(small_capture, MAIN32_RULES) + assert [leaf.rsplit(".", 1)[1] for leaf in report.mismatched] == ["is_240v"] + assert report.failing_rows() == {RowKey("circuit", "breaker/poles")} + + def test_a_published_property_that_reaches_nothing_is_silently_dropped( small_capture: Path, plant: Callable[[Plant], None] ) -> None: From 8fdf500f61d46cf55c264a2ef735a337347bd0fc Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:14:58 -0700 Subject: [PATCH 6/8] test(fidelity): pin each cell's valued drops and say what latent means The identity-and-topology drop rule covers whole classes of property, so a valued property it newly covers passed without review. The report now lists the rows dropped on purpose while carrying a value, and each MAIN 32 cell pins that set with the reason each drop is right, as the deferrals already are. A latent property is one the library's tables address that moved nothing when given a value, which the fate's description now says, rather than that it would be read. --- tests/adapter_fidelity/harness.py | 23 ++++++++++++++++-- .../adapter_fidelity/test_main32_fidelity.py | 24 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/tests/adapter_fidelity/harness.py b/tests/adapter_fidelity/harness.py index ae60bcf..1057231 100644 --- a/tests/adapter_fidelity/harness.py +++ b/tests/adapter_fidelity/harness.py @@ -81,9 +81,13 @@ class Fate(StrEnum): CONTROL = "control/readiness only" INTENTIONAL = "intentionally dropped" LATENT = "latent" - """Declared and never valued here; the library addresses it, so it would be read.""" + """Declared and never valued here; the library's tables address it, yet a value given to it moved nothing. + + A drop waiting to happen if the panel starts publishing it, unless the read is + conditional on something this capture does not have. + """ INERT = "inert" - """Declared and never valued here; nothing reads it.""" + """Declared and never valued here; nothing addresses it, and a value given to it moved nothing.""" SILENT = "silently dropped" """Valued, with no effect anywhere, and no code that says why.""" @@ -215,6 +219,12 @@ class FidelityReport: silently_dropped: tuple[RowKey, ...] deferred: tuple[RowKey, ...] """Rows that would be silently dropped, held back by a reason in `CellRules.deferred`.""" + dropped_with_a_value: tuple[RowKey, ...] + """Rows with a valued instance dropped on purpose, deferrals aside. + + A drop rule covers whole classes of property, so a cell pins this set: a + valued property newly dropped by a rule is reviewed before it passes. + """ fabricated: tuple[str, ...] # snapshot leaf paths mismatched: tuple[str, ...] # snapshot leaf paths whose value breaks the transform declared_property_instances: int @@ -828,6 +838,15 @@ def run_cell(tree_path: Path, rules: CellRules) -> FidelityReport: rows={key: tuple(sorted(found, key=_FATE_ORDER.__getitem__)) for key, found in sorted(fates.items())}, silently_dropped=tuple(sorted({instance.key for instance in instances if instance.fate == Fate.SILENT})), deferred=tuple(sorted({instance.key for instance in instances if instance.deferred})), + dropped_with_a_value=tuple( + sorted( + { + instance.key + for instance in instances + if instance.fate == Fate.INTENTIONAL and instance.raw is not None and not instance.deferred + } + ) + ), fabricated=tuple(sorted(fabricated)), mismatched=tuple(sorted(mismatched)), declared_property_instances=len(instances), diff --git a/tests/adapter_fidelity/test_main32_fidelity.py b/tests/adapter_fidelity/test_main32_fidelity.py index 6f957bd..c91ef42 100644 --- a/tests/adapter_fidelity/test_main32_fidelity.py +++ b/tests/adapter_fidelity/test_main32_fidelity.py @@ -11,6 +11,7 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from functools import cache import hashlib @@ -34,19 +35,36 @@ class _Cell: """Of the file as published at the eBus panel simulator's v0.9.0 tag.""" deferred: tuple[RowKey, ...] """The rows this capture values and the library drops today, each deferred in the rules with its reason.""" + dropped_with_a_value: Mapping[RowKey, str] + """The valued rows a drop rule covers here, each reviewed, with why dropping it is right.""" +_PEER_TYPE = ( + "the declared type of the device the record names, which the library reads from that device's own " + "$description when it sorts the tree" +) +_DISPATCH = "chooses which adapter parses the tree, so it is consumed before any snapshot exists" + CELLS = { "main32_r202639": _Cell( capture=FIXTURES / "main32_r202639-tree-v1.json", sha256="bf16d8ce21c2b685496700310286198664c6be1a62ea0fe12b1f15144ebfc086", deferred=(), + dropped_with_a_value={ + RowKey("circuit", "connection/feeds-device-type"): _PEER_TYPE, + RowKey("lugs-upstream", "connection/fed-by-device-type"): _PEER_TYPE, + RowKey("panel", "info/data-model-version"): _DISPATCH, + }, ), "main32_r202633": _Cell( capture=FIXTURES / "main32-tree-v1.json", sha256="0e92bbdca40f7d8d471e8edbfe2b49f986f2995512025bc67ffb4fcf20f7dd30", # The upstream lugs here are fed by another panel, and report that link OK. deferred=(RowKey("lugs-upstream", "connection/fed-by-device-status"),), + dropped_with_a_value={ + RowKey("lugs-upstream", "connection/fed-by-device-type"): _PEER_TYPE, + RowKey("panel", "info/data-model-version"): _DISPATCH, + }, ), } @@ -77,6 +95,12 @@ def test_only_the_recorded_rows_are_deferred(cell: str) -> None: assert _report(cell).deferred == CELLS[cell].deferred +@pytest.mark.parametrize("cell", sorted(CELLS)) +def test_only_the_reviewed_rows_are_dropped_with_a_value(cell: str) -> None: + """A drop rule covers a class of property; a valued one it newly covers waits for review here.""" + assert _report(cell).dropped_with_a_value == tuple(sorted(CELLS[cell].dropped_with_a_value)) + + @pytest.mark.parametrize("cell", sorted(CELLS)) def test_no_value_is_fabricated(cell: str) -> None: report = _report(cell) From 24dd8ad9e008da83874c913074e873202db66f05 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:18:34 -0700 Subject: [PATCH 7/8] docs: drop an internal design-note path from a docstring and the changelog The client-rebuild docstring and the 2.6.4 changelog entry cited the path of a design note that is not published, so a reader could not follow it. Both already describe the recovery in full, so the citation goes and nothing replaces it. --- CHANGELOG.md | 3 +-- src/span_panel_api/mqtt/connection.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7241445..1ebc517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -506,8 +506,7 @@ Everything below is additive. Each field is `None` or empty on a panel that publ `MQTT_FULL_REBUILD_AFTER_FAILURES` (3) consecutive failures, or immediately on any `ssl.SSLError`. The previous behavior pinned the panel's CA certificate into the paho client once at `connect()` time and re-used it across all reconnect attempts; if the panel rotated its private CA — most plausibly during a firmware upgrade — every subsequent reconnect raised `ssl.SSLCertVerificationError` (caught by the broad `OSError` clause and silently retried) and the bridge could not recover without a config-entry reload. The rebuild mirrors what a manual reload does without going through HA's `config_entry` teardown, so entities stay registered and the integration's grace-period logic continues to apply unchanged. The threshold-cadence design (counter reset on - every rebuild attempt, success or fail) keeps the recovery path active throughout extended outages — multi-day disconnections recover whenever the panel becomes usable again, including if the CA rotates a second time mid-outage. See - `SpanPanel_Docs/span-panel-api/2026-05-17-mqtt-ca-refresh-on-reconnect-design.md` for the full design. + every rebuild attempt, success or fail) keeps the recovery path active throughout extended outages — multi-day disconnections recover whenever the panel becomes usable again, including if the CA rotates a second time mid-outage. ### Added diff --git a/src/span_panel_api/mqtt/connection.py b/src/span_panel_api/mqtt/connection.py index 2ddb761..6846cae 100644 --- a/src/span_panel_api/mqtt/connection.py +++ b/src/span_panel_api/mqtt/connection.py @@ -846,8 +846,7 @@ async def _rebuild_client(self) -> bool: retrying with it. Recovery target: CA rotation (firmware upgrade), stale paho client - internal state, stuck Homie accumulator. See the design doc at - SpanPanel_Docs/span-panel-api/2026-05-17-mqtt-ca-refresh-on-reconnect-design.md. + internal state, stuck Homie accumulator. """ if self._loop is None: return False From 716de979cb301c38ff8eac69c08b60c4449ad019 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:09:24 -0700 Subject: [PATCH 8/8] chore(release): span-panel-api 3.7.0b1, schema-0 1.3.0b1 and schema-1 1.3.0b1 Betas of 3.7.0 and the two adapters' 1.3.0. Both adapters now floor on span-panel-api 3.7.0b1, whose snapshot fields accept the unknown values they report, and the schema-0 and schema-1 extras floor on the 1.3.0b1 adapters. Beta floors name betas, as RELEASE.md describes; the finals restore stable ones. The changelog entries stay under Unreleased, since a beta's bump changes no heading. --- CHANGELOG.md | 1 + packages/schema-0/pyproject.toml | 6 ++++-- packages/schema-1/pyproject.toml | 7 +++++-- pyproject.toml | 9 ++++++--- uv.lock | 6 +++--- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ebc517..09036d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Snapshot fields that held a fabricated constant can now be unknown, and with `sp - **BREAKING FOR CONSUMERS: `SpanPanelSnapshot.uptime_s` becomes `int | None`**, and with those parsers it is `None` on a parent/child panel and before a flat panel first reports ready, where it was a constant `0`. - **BREAKING FOR CONSUMERS: `SpanCircuitSnapshot.instant_power_update_time_s` and `energy_accum_update_time_s` become `int | None` and default to `None`**, and with those parsers they are `None` until the reading has arrived and always on a parent/child panel, where they were `0`. +- **The `schema-0` and `schema-1` extras require `span-panel-api-schema-0` and `span-panel-api-schema-1` 1.3.0 or newer**, the adapters that report these values as unknown. ### Removed diff --git a/packages/schema-0/pyproject.toml b/packages/schema-0/pyproject.toml index 781182d..cae9aed 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.2.0" +version = "1.3.0b1" description = "Flat-schema (data-model-version absent) parser for span-panel-api" authors = [ {name = "SpanPanel"} @@ -19,7 +19,9 @@ dependencies = [ # Raised to 3.6.0, the first bootstrap whose snapshot carries `pv_inverters` # and the inverter's `device_id` and `node_id`, which this parser fills. # Against an earlier one, building a snapshot fails on those arguments. - "span-panel-api>=3.6.0,<4.0", + # Raised to 3.7.0b1, whose snapshot fields accept the unknown values this + # parser now reports, where an earlier one types them as never unknown. + "span-panel-api>=3.7.0b1,<4.0", ] [project.urls] diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index 7222937..57294b1 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 = "1.2.2" +version = "1.3.0b1" description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api" authors = [ {name = "SpanPanel"} @@ -28,7 +28,10 @@ dependencies = [ # Raised to 3.6.1, whose SpanPanelSnapshot.pv documents what this parser builds. # Raised to 3.6.2, which waits on this parser's missing labels after a bridge # rebuild as well as on connect; the fix spans both, so the pair moves together. - "span-panel-api>=3.6.2,<4.0", + # Raised to 3.7.0b1, whose snapshot fields accept the unknown values this + # parser now reports: against an earlier one, the update times it leaves + # unset read as a constant 0. + "span-panel-api>=3.7.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 diff --git a/pyproject.toml b/pyproject.toml index e129dcb..f2af8a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "3.6.2" +version = "3.7.0b1" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} @@ -62,11 +62,14 @@ dependencies = [ # so a 1.1.x adapter is still accepted, but it fills neither `pv_inverters` nor # the inverter identity fields 3.6.0 adds. Upgrading through the extra is what # brings the adapters that do. -schema-0 = ["span-panel-api-schema-0>=1.2.0"] +# Raised to 1.3.0b1 for 3.7.0b1: that adapter reports the unknown values this +# bootstrap's snapshot fields now accept, and stops synthesising unoccupied positions. +schema-0 = ["span-panel-api-schema-0>=1.3.0b1"] # Raised to 1.2.1 for 3.6.1: that adapter builds the `pv` that `SpanPanelSnapshot.pv` now documents. # Raised to 1.2.2 for 3.6.2: the readiness gate every snapshot passes asks that # adapter what is still missing, and 1.2.2 restarts its feed grace correctly. -schema-1 = ["span-panel-api-schema-1>=1.2.2"] +# Raised to 1.3.0b1 for 3.7.0b1, for the same reason as schema-0's. +schema-1 = ["span-panel-api-schema-1>=1.3.0b1"] [project.urls] Homepage = "https://github.com/SpanPanel/span-panel-api" diff --git a/uv.lock b/uv.lock index 6e0a858..ea51535 100644 --- a/uv.lock +++ b/uv.lock @@ -974,7 +974,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "3.6.2" +version = "3.7.0b1" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -1046,7 +1046,7 @@ dev = [ [[package]] name = "span-panel-api-schema-0" -version = "1.2.0" +version = "1.3.0b1" source = { editable = "packages/schema-0" } dependencies = [ { name = "span-panel-api" }, @@ -1057,7 +1057,7 @@ requires-dist = [{ name = "span-panel-api", editable = "." }] [[package]] name = "span-panel-api-schema-1" -version = "1.2.2" +version = "1.3.0b1" source = { editable = "packages/schema-1" } dependencies = [ { name = "ebus-sdk" },