span-panel-api-schema-1 0.1.0b1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ __pycache__/
2
+ build/
3
+ dist/
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ .cursor/
7
+ .cursorignore
8
+ .cursor
9
+ .cursorindexingignore
10
+ xnotes/*
11
+
12
+ # pyenv
13
+ .python-version
14
+
15
+ # Environments
16
+ .env
17
+ .envrc
18
+ .venv
19
+ .vscode/settings.json
20
+
21
+ # mypy
22
+ .mypy_cache/
23
+ .dmypy.json
24
+ dmypy.json
25
+
26
+ # ruff
27
+ .ruff_cache/
28
+
29
+ # JetBrains
30
+ .idea/
31
+
32
+ /coverage.xml
33
+ /.coverage
34
+ coverage_output.log
35
+ **/.DS_Store
36
+ .local_coverage_data
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ All notable changes to `span-panel-api-schema-1` are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is the parent/child device tree SPAN firmware `r202633+` publishes, identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version
8
+ number. A release here means this parser changed, never that the panel did.
9
+
10
+ ## [0.1.0b1] - 08/2026
11
+
12
+ Pre-release. First release as a standalone distribution, and the first parser for the parent/child data model.
13
+
14
+ ### Added
15
+
16
+ - **`SchemaOneAdapter`**, registered as `schema_1` under the `span_panel_api.schema_adapters` entry-point group. A panel reporting `data-model-version` `1.x` resolves to it; a panel without this package installed still gets the named
17
+ `SpanPanelAdapterMissingError`, so installing it is the opt-in.
18
+ - **`ControllerRoutes`** — an `ebus_sdk.MqttControllerTransport` that records `Controller`'s subscriptions instead of making them, so the SDK parses the tree over span-panel-api's own connection to the panel's broker. The adapter is built before a
19
+ connection exists and never receives one; a single wildcard subscription made by the transport layer covers the whole tree, and this routes each message to whichever SDK callback asked for it.
20
+ - **The snapshot mapper.** Sorts the tree by declared device type — never by device id — and maps it onto `SpanPanelSnapshot`: circuits, both lugs, the MID, and the BESS/PV/EVSE devices.
21
+ - **Panel size from `info/model`** via `PANEL_SIZE_BY_MODEL`, which is what restores the unmapped-position entries the integration builds from the difference between total and occupied spaces. `info/spaces` has no format and the panel publishes no size
22
+ property, so the model is the only source; `panel_model_drift()` reports a model the panel declares that we have no size for, because the alternative is a user noticing missing positions.
23
+ - **Field metadata read from each device's `$description`** rather than a schema document. The same capability type exposes different properties on different device classes — `meter` is voltage on the panel, power and energy on a circuit, both currents on
24
+ lugs — so the per-device description is what this panel actually has.
25
+ - **A `py.typed` marker**, so consumers type-check against this package's real annotations.
26
+
27
+ ### Known deviations and deliberate gaps
28
+
29
+ - **`set_dominant_power_source_topic()` returns `None`.** The v1.0 property split into `grid-forming-entity` and `asserted-islanding-state`, which are different controls on different devices rather than a rename. `None` makes the transport reject the
30
+ command instead of publishing where nothing listens; which successor to expose is a product decision.
31
+ - **`dsm_state`, `current_run_config`, `grid_islandable` and `pv.relative_position`** have no direct v1.0 equivalent and are left to the product decisions tracked separately. Fields the mapper declines carry no metadata row, so the integration never
32
+ validates against a field nothing populates.
33
+
34
+ ### Fixed before first release
35
+
36
+ Both found by verifying reconnect against a live broker, and both presented as a healthy connection.
37
+
38
+ - **Messages arriving before the SDK registered a route for them were dropped.** `Controller` learns its topics as it walks the tree, but one subscription delivers the whole tree at once in whatever order the broker replays its retained store. Seeded
39
+ children-first, a 40-space panel parsed as zero circuits. Unrouted messages are now held and released when the matching route appears — the value a per-device subscription would have been given at subscribe time — with a ceiling so an unclaimed subtree
40
+ cannot leak.
41
+ - **Readiness asked only about the root**, so a connection completed with a fraction of its circuits and no panel size. It now waits for every declared device to describe itself, at any depth. Child _state_ is deliberately not required, so an offline DER
42
+ does not block a connection; the model is required only when the root's description declares it.
43
+ - **`grid_state` read the wrong one of the MID's two grid properties.** The MID publishes both `grid/islanding-state` (`ON_GRID`/`OFF_GRID`/`UNKNOWN`) and `grid/grid-state` (`UP`/`DOWN`/`DEGRADED`/`UNKNOWN`). The flat schema's `grid_state` was the BESS's
44
+ `grid-state`, an islanding answer, so its successor is `islanding-state`; `grid/grid-state` asks whether the utility supply is healthy and is new in v1.0 with no flat equivalent. Matching on the property name rather than the value set put `UP` where a
45
+ consumer expects `ON_GRID` — an entity keeping its id and history while its vocabulary silently changed. `grid/grid-state` is left unmapped, being a new signal rather than a replacement for an existing field.
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: span-panel-api-schema-1
3
+ Version: 0.1.0b1
4
+ Summary: Parent/child schema (data-model-version 1.x) parser for span-panel-api
5
+ Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
6
+ Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
7
+ Author: SpanPanel
8
+ License-Expression: MIT
9
+ Requires-Python: <4.0,>=3.10
10
+ Requires-Dist: ebus-sdk<1.0,>=0.18.0
11
+ Requires-Dist: span-panel-api<4.0,>=3.0.0b1
12
+ Description-Content-Type: text/markdown
13
+
14
+ # span-panel-api-schema-1
15
+
16
+ Parent/child schema parser (`data-model-version` 1.x, SPAN firmware r202633+) for [span-panel-api](https://github.com/SpanPanel/span-panel-api).
17
+
18
+ **Status: incomplete.** This distribution does not yet register a `schema_1` adapter, so installing it does not make a parent/child panel work. A 1.x panel still raises `SpanPanelAdapterMissingError` naming `schema_1`, which is the honest answer until the
19
+ parser can build a snapshot.
20
+
21
+ What exists today is `BridgeControllerTransport` — an `ebus_sdk.MqttControllerTransport` backed by span-panel-api's own MQTT connection, so the eBus SDK can parse the parent/child tree while the connection to the panel's broker stays ours.
@@ -0,0 +1,8 @@
1
+ # span-panel-api-schema-1
2
+
3
+ Parent/child schema parser (`data-model-version` 1.x, SPAN firmware r202633+) for [span-panel-api](https://github.com/SpanPanel/span-panel-api).
4
+
5
+ **Status: incomplete.** This distribution does not yet register a `schema_1` adapter, so installing it does not make a parent/child panel work. A 1.x panel still raises `SpanPanelAdapterMissingError` naming `schema_1`, which is the honest answer until the
6
+ parser can build a snapshot.
7
+
8
+ What exists today is `BridgeControllerTransport` — an `ebus_sdk.MqttControllerTransport` backed by span-panel-api's own MQTT connection, so the eBus SDK can parse the parent/child tree while the connection to the panel's broker stays ours.
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "span-panel-api-schema-1"
3
+ version = "0.1.0b1"
4
+ description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api"
5
+ authors = [
6
+ {name = "SpanPanel"}
7
+ ]
8
+ readme = "README.md"
9
+ license = "MIT"
10
+ requires-python = ">=3.10,<4.0"
11
+ dependencies = [
12
+ "span-panel-api>=3.0.0b1,<4.0",
13
+ # Only this distribution depends on the eBus SDK. The bootstrap and
14
+ # schema-0 stay clean, so a flat-panel install never pulls it in — which is
15
+ # what bounds the release coupling this dependency introduces to panels on
16
+ # r202633+.
17
+ "ebus-sdk>=0.18.0,<1.0",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/SpanPanel/span-panel-api"
22
+ Issues = "https://github.com/SpanPanel/span-panel-api/issues"
23
+
24
+ # The whole point of this distribution. Dispatch resolves `schema_1` for a 1.x
25
+ # panel by discovering this group, never by importing this package.
26
+ #
27
+ # Held back until the parser could answer for a panel end to end — mapper,
28
+ # field metadata, command topics, and recovery from a real broker outage.
29
+ # Installing this package remains the opt-in: a 1.x panel without it still gets
30
+ # the named SpanPanelAdapterMissingError rather than a silent misparse.
31
+ [project.entry-points."span_panel_api.schema_adapters"]
32
+ schema_1 = "span_panel_api_schema_1:SchemaOneAdapter"
33
+
34
+ [tool.uv.sources]
35
+ span-panel-api = { workspace = true }
36
+
37
+ [build-system]
38
+ requires = ["hatchling"]
39
+ build-backend = "hatchling.build"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/span_panel_api_schema_1"]
@@ -0,0 +1,6 @@
1
+ """Parent/child schema (data-model-version 1.x) parser for span-panel-api."""
2
+
3
+ from span_panel_api_schema_1.adapter import SchemaOneAdapter
4
+ from span_panel_api_schema_1.transport import ControllerRoutes
5
+
6
+ __all__ = ["ControllerRoutes", "SchemaOneAdapter"]
@@ -0,0 +1,255 @@
1
+ """Parent/child adapter: `ebus_sdk.Controller` behind the `SchemaAdapter` protocol.
2
+
3
+ The SDK does the Homie work — walking `$description.children`, gating each
4
+ child's subscription on its parent reaching `ready`, and cascading state down
5
+ the tree. This adapter supplies the transport it parses over, sorts the result
6
+ into a `SpanPanelSnapshot`, and builds the topics the transport publishes
7
+ commands to.
8
+
9
+ **It never touches the connection.** `SchemaAdapter` instances are built before
10
+ one exists, so `Controller` is given a route table (`ControllerRoutes`) that
11
+ records its subscriptions instead of making them, and this adapter asks for one
12
+ broad subscription up front through `topics_to_subscribe()`. Every message then
13
+ arrives via `handle_message` and is routed to whichever SDK callback wanted it.
14
+ A reconnect re-subscribes that same static list, the broker replays the retained
15
+ tree, and the SDK repopulates — so there is no resync hook to wire or forget.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from typing import TYPE_CHECKING
22
+
23
+ from ebus_sdk import Controller
24
+
25
+ from span_panel_api_schema_1.const import (
26
+ HOMIE_DOMAIN,
27
+ HOMIE_VERSION,
28
+ NODE_INFO,
29
+ NODE_LOAD_SHED,
30
+ NODE_SWITCH,
31
+ PROP_MODEL,
32
+ PROP_NAME,
33
+ PROP_PRIORITY,
34
+ PROP_RELAY,
35
+ STATE_READY,
36
+ )
37
+ from span_panel_api_schema_1.field_metadata import build_field_metadata
38
+ from span_panel_api_schema_1.snapshot import TreeRoles, build_snapshot, device_type
39
+ from span_panel_api_schema_1.transport import ControllerRoutes
40
+
41
+ if TYPE_CHECKING:
42
+ from collections.abc import Callable
43
+
44
+ from ebus_sdk.homie import DiscoveredDevice
45
+
46
+ from span_panel_api.models import FieldMetadata, SpanPanelSnapshot, V2HomieSchema
47
+
48
+ _LOGGER = logging.getLogger(__name__)
49
+
50
+
51
+ class SchemaOneAdapter:
52
+ """Parser for the parent/child schema (data-model-version 1.x)."""
53
+
54
+ schema_major = "schema_1"
55
+ SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = (">=1.0", "<2.0")
56
+
57
+ def __init__(self, serial_number: str, schema: V2HomieSchema) -> None:
58
+ self._serial_number = serial_number
59
+ self._schema = schema
60
+ self._routes = ControllerRoutes()
61
+ self._controller = Controller(root_device_id=serial_number, mqttc=self._routes)
62
+ self._property_callbacks: list[Callable[[str, str, str, str | None], None]] = []
63
+ self._awaiting: tuple[str, ...] = ()
64
+ self._controller.set_on_property_changed_callback(self._on_property_changed)
65
+ # Records the subscriptions the tree walk needs; nothing reaches the
66
+ # wire, because this object has no connection to reach it with.
67
+ self._controller.start_discovery()
68
+
69
+ # -- SchemaAdapter -----------------------------------------------------
70
+
71
+ def topics_to_subscribe(self) -> list[str]:
72
+ """One subscription covering the whole tree.
73
+
74
+ Deliberately broader than the SDK's own per-device subscriptions,
75
+ because the adapter is asked this once at connect and again after a
76
+ reconnect — it has no way to add one when a child announces later. The
77
+ flat adapter takes the same approach with `ebus/5/{serial}/#`; here the
78
+ wildcard spans devices, since children are peers of the panel in the
79
+ topic tree rather than nodes beneath it.
80
+ """
81
+ return [f"{HOMIE_DOMAIN}/{HOMIE_VERSION}/#"]
82
+
83
+ def handle_message(self, topic: str, payload: str) -> None:
84
+ self._routes.dispatch(topic, payload)
85
+
86
+ def is_ready(self) -> bool:
87
+ """Ready when the whole declared tree has described itself.
88
+
89
+ The flat schema gets its entire topology in one `$description`, so
90
+ "described" and "complete" are the same event. Under parent/child the
91
+ topology arrives as one description per device, and the root's says
92
+ ready as soon as *its own* arrives — while its children are still
93
+ landing. Treating that as ready hands the transport a panel with a
94
+ handful of circuits and no model, which it reports as a healthy
95
+ connection. So readiness waits for every device the tree declares.
96
+
97
+ Child *state* is deliberately not required. A commissioned DER that is
98
+ currently offline publishes `lost` but keeps its retained description,
99
+ and a panel should not fail to connect because a battery is unplugged.
100
+
101
+ The model is required only when the root's description declares it: the
102
+ panel's size comes from nowhere else, and a snapshot built a moment too
103
+ early reports zero spaces, which erases every unmapped position rather
104
+ than merely mis-stating a number. Asking only for what the panel itself
105
+ promised keeps a firmware that omits the property connectable — it
106
+ falls back to the drift warning in `panel_size_from_model`.
107
+ """
108
+ root = self._controller.get_root(self._serial_number)
109
+ if root is None or root.state != STATE_READY or not root.description:
110
+ return False
111
+ if self._awaiting_descriptions(root):
112
+ return False
113
+ return self._model_arrived(root)
114
+
115
+ def build_snapshot(self) -> SpanPanelSnapshot:
116
+ root = self._require_root()
117
+ return build_snapshot(root, self._children())
118
+
119
+ def build_field_metadata(self) -> dict[str, FieldMetadata]:
120
+ root = self._controller.get_root(self._serial_number)
121
+ devices = [] if root is None else [root, *self._children()]
122
+ return build_field_metadata(devices)
123
+
124
+ def circuit_nodes_missing_names(self) -> list[str]:
125
+ """Devices whose retained identity has not arrived yet.
126
+
127
+ The transport polls this during connect so the first snapshot carries
128
+ real names rather than falling back to identifiers.
129
+
130
+ Readiness proves the tree's *shape* — every device the tree declares
131
+ has described itself. It cannot prove the tree's *labels*: a
132
+ description says which properties exist, and their retained values
133
+ arrive as separate messages that may land after the last description
134
+ does. That gap exists under the flat schema too; it just matters more
135
+ here, because a DER is its own device and the integration registers it
136
+ from this first snapshot.
137
+
138
+ Named for the flat schema's circuits, where a missing name was the only
139
+ way to get a placeholder. Under parent/child every mapped device has
140
+ the same exposure, so a DER missing the model it declared is reported
141
+ alongside a circuit missing its name.
142
+ """
143
+ roles = TreeRoles(self._children())
144
+ missing = [circuit.device_id for circuit in roles.circuits if not circuit.get_property(NODE_INFO, PROP_NAME)]
145
+ ders = (roles.bess, roles.pv, *roles.evse)
146
+ missing.extend(
147
+ device.device_id
148
+ for device in ders
149
+ if device is not None
150
+ and PROP_MODEL in device.get_node_properties(NODE_INFO)
151
+ and device.get_property(NODE_INFO, PROP_MODEL) is None
152
+ )
153
+ return missing
154
+
155
+ def find_node_by_type(self, type_str: str) -> str | None:
156
+ """Return the id of the first device declaring `type_str`.
157
+
158
+ Named for the flat schema's nodes; under parent/child the same question
159
+ is asked of devices, and the answer is a device id.
160
+ """
161
+ for device in self._children():
162
+ if device_type(device) == type_str:
163
+ return device.device_id
164
+ return None
165
+
166
+ # -- Command topics ----------------------------------------------------
167
+ #
168
+ # The adapter names the topic and the transport publishes it, so commanding
169
+ # a panel needs no connection here either.
170
+
171
+ def set_circuit_relay_topic(self, circuit_id: str) -> str:
172
+ return self._set_topic(circuit_id, NODE_SWITCH, PROP_RELAY)
173
+
174
+ def set_circuit_priority_topic(self, circuit_id: str) -> str:
175
+ return self._set_topic(circuit_id, NODE_LOAD_SHED, PROP_PRIORITY)
176
+
177
+ def set_dominant_power_source_topic(self) -> str | None:
178
+ """No v1.0 equivalent, so no topic.
179
+
180
+ `dominant-power-source` split into `grid-forming-entity` and
181
+ `asserted-islanding-state`, which are different controls on different
182
+ devices rather than a renamed one. Returning None makes the transport
183
+ reject the command instead of publishing to a topic nothing serves —
184
+ and which successor to expose is a product decision, tracked in the
185
+ entity and config deltas write-up.
186
+ """
187
+ return None
188
+
189
+ def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]:
190
+ """Subscribe to per-property updates; returns an unregister callable."""
191
+ self._property_callbacks.append(callback)
192
+
193
+ def _unregister() -> None:
194
+ if callback in self._property_callbacks:
195
+ self._property_callbacks.remove(callback)
196
+
197
+ return _unregister
198
+
199
+ # -- internals ---------------------------------------------------------
200
+
201
+ def _set_topic(self, device_id: str, node: str, prop: str) -> str:
202
+ return f"{HOMIE_DOMAIN}/{HOMIE_VERSION}/{device_id}/{node}/{prop}/set"
203
+
204
+ def _require_root(self) -> DiscoveredDevice:
205
+ """The root, or a clear error if discovery has not finished.
206
+
207
+ Checks readiness rather than existence: `start_discovery` pre-creates
208
+ the root entry so descendants have somewhere to attach, so the device
209
+ object exists from construction and proves nothing on its own.
210
+ """
211
+ root = self._controller.get_root(self._serial_number)
212
+ if root is None or not self.is_ready():
213
+ raise RuntimeError(f"Device tree for {self._serial_number!r} is not ready; build_snapshot called too early")
214
+ return root
215
+
216
+ def _children(self) -> list[DiscoveredDevice]:
217
+ return list(self._controller.get_descendants(self._serial_number))
218
+
219
+ def _awaiting_descriptions(self, root: DiscoveredDevice) -> tuple[str, ...]:
220
+ """Devices the tree declares that have not described themselves yet.
221
+
222
+ Walks declarations rather than discoveries, and at any depth: a child
223
+ may declare children of its own, and those count too. Logged when the
224
+ set changes, because the alternative diagnostic for a tree that never
225
+ completes is a bare 30-second connect timeout.
226
+ """
227
+ described = {device.device_id: device for device in self._children() if device.description is not None}
228
+ awaiting = {
229
+ child_id
230
+ for device in (root, *described.values())
231
+ for child_id in device.children_ids
232
+ if child_id not in described
233
+ }
234
+ pending = tuple(sorted(awaiting))
235
+ if pending != self._awaiting:
236
+ self._awaiting = pending
237
+ if pending:
238
+ _LOGGER.debug("Waiting on %d declared devices: %s", len(pending), ", ".join(pending))
239
+ return pending
240
+
241
+ def _model_arrived(self, root: DiscoveredDevice) -> bool:
242
+ """Whether the panel has published the model it said it would."""
243
+ if PROP_MODEL not in root.get_node_properties(NODE_INFO):
244
+ return True
245
+ return root.get_property(NODE_INFO, PROP_MODEL) is not None
246
+
247
+ def _on_property_changed(self, device_id: str, node_id: str, property_id: str, value: str, _old: str | None) -> None:
248
+ """Fan a Controller property change out to registered consumers.
249
+
250
+ Signature adapts the SDK's five arguments to the protocol's four: the
251
+ protocol has no place for the previous value, and consumers that need
252
+ one keep it themselves.
253
+ """
254
+ for callback in list(self._property_callbacks):
255
+ callback(device_id, node_id, property_id, value)
@@ -0,0 +1,177 @@
1
+ """Map a v1.0 circuit device onto ``SpanCircuitSnapshot``.
2
+
3
+ The snapshot's field names come from the v1 REST API and are preserved so the
4
+ integration's entities do not move. Three of them no longer have a property to
5
+ read, because v1.0 consolidated four flat mechanisms into two. Their
6
+ derivations are defined by the migration guide, not invented here:
7
+
8
+ ====================== ===========================================================
9
+ Flat property v1.0 source
10
+ ====================== ===========================================================
11
+ ``always-on`` ``switch/relay-controllable``, inverted
12
+ ``never-backup`` ``$settable`` on ``load-shed/priority``, inverted
13
+ ``sheddable`` computed: ``priority != NEVER and relay-controllable``
14
+ ====================== ===========================================================
15
+
16
+ Sign and direction are unchanged from the flat schema, and both are the reverse
17
+ of what the property names suggest. Values are in the enclosure's reference
18
+ frame: a normal load reads **negative** ``active-power`` and accumulates
19
+ ``exported-energy`` (the panel exported it *to* the circuit). The snapshot
20
+ reports consumption as positive, so power is negated and the two energy
21
+ accumulators are swapped.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import TYPE_CHECKING
27
+
28
+ from span_panel_api.models import SpanCircuitSnapshot
29
+ from span_panel_api_schema_1.const import (
30
+ ATTR_SETTABLE,
31
+ NODE_BREAKER,
32
+ NODE_INFO,
33
+ NODE_LOAD_SHED,
34
+ NODE_METER,
35
+ NODE_SWITCH,
36
+ PRIORITY_NEVER,
37
+ PROP_ACTIVE_POWER,
38
+ PROP_CURRENT,
39
+ PROP_EXPORTED_ENERGY,
40
+ PROP_IMPORTED_ENERGY,
41
+ PROP_NAME,
42
+ PROP_POLES,
43
+ PROP_PRIORITY,
44
+ PROP_RATING,
45
+ PROP_RELAY,
46
+ PROP_RELAY_CONTROLLABLE,
47
+ PROP_RELAY_REQUESTER,
48
+ PROP_SPACES,
49
+ UNKNOWN,
50
+ )
51
+
52
+ if TYPE_CHECKING:
53
+ from ebus_sdk.homie import DiscoveredDevice
54
+
55
+
56
+ def _text(device: DiscoveredDevice, node: str, prop: str, default: str = "") -> str:
57
+ value = device.get_property(node, prop)
58
+ return default if value is None else str(value)
59
+
60
+
61
+ def _number(device: DiscoveredDevice, node: str, prop: str) -> float | None:
62
+ """Read a numeric property, or None when it is absent or unparseable.
63
+
64
+ Unparseable is treated as absent rather than as an error: a single
65
+ malformed value must not take down a whole snapshot, and the field it
66
+ feeds is optional.
67
+ """
68
+ raw = device.get_property(node, prop)
69
+ if raw is None or raw == "":
70
+ return None
71
+ try:
72
+ return float(raw)
73
+ except (TypeError, ValueError):
74
+ return None
75
+
76
+
77
+ def _flag(device: DiscoveredDevice, node: str, prop: str, *, default: bool) -> bool:
78
+ """Read a Homie boolean. Absent means `default`, which is not always False.
79
+
80
+ `relay-controllable` absent has to mean *controllable*, because the
81
+ property exists to mark the exception (an always-on circuit). Defaulting it
82
+ to False would silently make every circuit uncontrollable on a panel that
83
+ omits it.
84
+ """
85
+ raw = device.get_property(node, prop)
86
+ if raw is None or raw == "":
87
+ return default
88
+ return str(raw).strip().lower() == "true"
89
+
90
+
91
+ def _tabs(device: DiscoveredDevice) -> list[int]:
92
+ """Breaker spaces from ``info/spaces``.
93
+
94
+ v1.0 publishes the occupied spaces literally (``"36,38"``), where the flat
95
+ schema published one space plus a `dipole` flag and left the consumer to
96
+ infer the second as ``space + 2``. Reading the list means a 3-pole breaker
97
+ reports three tabs instead of being silently truncated to two.
98
+ """
99
+ raw = _text(device, NODE_INFO, PROP_SPACES)
100
+ if not raw:
101
+ return []
102
+ tabs: list[int] = []
103
+ for part in raw.split(","):
104
+ part = part.strip()
105
+ if not part:
106
+ continue
107
+ try:
108
+ tabs.append(int(part))
109
+ except ValueError:
110
+ continue
111
+ return tabs
112
+
113
+
114
+ def _priority_is_settable(device: DiscoveredDevice) -> bool:
115
+ """Whether ``load-shed/priority`` is user-settable on this circuit.
116
+
117
+ This is the successor to the flat ``never-backup`` boolean, and it is read
118
+ from the description rather than from a value topic — v1.0 expresses
119
+ never-backup as *mutability*, so the signal is the Homie ``$settable``
120
+ attribute on the property definition.
121
+
122
+ Absent means settable: locking is the exception a panel announces, so
123
+ treating an unannounced circuit as locked would mark every circuit
124
+ never-backup on a panel that does not publish the attribute.
125
+ """
126
+ definition = device.get_node_properties(NODE_LOAD_SHED).get(PROP_PRIORITY)
127
+ if not isinstance(definition, dict):
128
+ return True
129
+ settable = definition.get(ATTR_SETTABLE)
130
+ if settable is None:
131
+ return True
132
+ if isinstance(settable, bool):
133
+ return settable
134
+ return str(settable).strip().lower() != "false"
135
+
136
+
137
+ def build_circuit(
138
+ device: DiscoveredDevice, device_type: str = "circuit", relative_position: str = ""
139
+ ) -> SpanCircuitSnapshot:
140
+ """Build one circuit snapshot from its v1.0 device."""
141
+ raw_power = _number(device, NODE_METER, PROP_ACTIVE_POWER) or 0.0
142
+ # Negate so positive means consumption. The guard keeps -0.0 out of the
143
+ # snapshot, where it would compare equal to 0.0 but format as "-0.0".
144
+ instant_power_w = 0.0 if raw_power == 0.0 else -raw_power
145
+
146
+ relay_controllable = _flag(device, NODE_SWITCH, PROP_RELAY_CONTROLLABLE, default=True)
147
+ priority = _text(device, NODE_LOAD_SHED, PROP_PRIORITY, UNKNOWN)
148
+ priority_settable = _priority_is_settable(device)
149
+
150
+ return SpanCircuitSnapshot(
151
+ circuit_id=device.device_id,
152
+ name=_text(device, NODE_INFO, PROP_NAME),
153
+ relay_state=_text(device, NODE_SWITCH, PROP_RELAY, UNKNOWN),
154
+ instant_power_w=instant_power_w,
155
+ # The panel *imported* this energy from the circuit, so the circuit
156
+ # produced it. Named from the panel's perspective, reported from the
157
+ # circuit's.
158
+ produced_energy_wh=_number(device, NODE_METER, PROP_IMPORTED_ENERGY) or 0.0,
159
+ consumed_energy_wh=_number(device, NODE_METER, PROP_EXPORTED_ENERGY) or 0.0,
160
+ tabs=_tabs(device),
161
+ priority=priority,
162
+ # `always-on` is `not relay-controllable`, and the flat schema derived
163
+ # user-controllability from `always-on` — so this is the same answer by
164
+ # a shorter route.
165
+ is_user_controllable=relay_controllable,
166
+ is_sheddable=priority != PRIORITY_NEVER and relay_controllable,
167
+ is_never_backup=not priority_settable,
168
+ device_type=device_type,
169
+ relative_position=relative_position,
170
+ is_240v=(_number(device, NODE_BREAKER, PROP_POLES) or 1) >= 2,
171
+ current_a=_number(device, NODE_METER, PROP_CURRENT),
172
+ breaker_rating_a=_number(device, NODE_BREAKER, PROP_RATING),
173
+ always_on=not relay_controllable,
174
+ relay_requester=_text(device, NODE_SWITCH, PROP_RELAY_REQUESTER, UNKNOWN),
175
+ relay_state_target=device.get_property_target(NODE_SWITCH, PROP_RELAY),
176
+ priority_target=device.get_property_target(NODE_LOAD_SHED, PROP_PRIORITY),
177
+ )