span-panel-api-schema-1 1.2.2__tar.gz → 1.4.0__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.
Files changed (41) hide show
  1. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/.gitignore +1 -0
  2. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/CHANGELOG.md +32 -0
  3. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/PKG-INFO +4 -2
  4. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/README.md +2 -0
  5. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/pyproject.toml +8 -2
  6. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/src/span_panel_api_schema_1/adapter.py +283 -81
  7. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/src/span_panel_api_schema_1/adoption.py +16 -5
  8. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/src/span_panel_api_schema_1/charge_limit.py +42 -1
  9. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/src/span_panel_api_schema_1/circuits.py +139 -27
  10. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/src/span_panel_api_schema_1/const.py +44 -7
  11. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/src/span_panel_api_schema_1/description.py +15 -0
  12. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/src/span_panel_api_schema_1/devices.py +58 -10
  13. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/src/span_panel_api_schema_1/extension.py +10 -2
  14. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/src/span_panel_api_schema_1/field_metadata.py +66 -24
  15. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/src/span_panel_api_schema_1/panel.py +145 -87
  16. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/src/span_panel_api_schema_1/reference/parent_child_tree.json +18 -20
  17. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/src/span_panel_api_schema_1/snapshot.py +46 -33
  18. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/src/span_panel_api_schema_1/transport.py +86 -20
  19. span_panel_api_schema_1-1.4.0/src/span_panel_api_schema_1/tree_export.py +924 -0
  20. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/breaker.json +0 -0
  21. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/charge-limit.json +0 -0
  22. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/connection.json +0 -0
  23. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/door.json +0 -0
  24. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/grid-forming.json +0 -0
  25. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/grid.json +0 -0
  26. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/info.json +0 -0
  27. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/load-shed.json +0 -0
  28. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/meter.json +0 -0
  29. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/pcs.json +0 -0
  30. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/power-flows.json +0 -0
  31. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/shed-forecast.json +0 -0
  32. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/shed.json +0 -0
  33. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/soc.json +0 -0
  34. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/status.json +0 -0
  35. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/switch.json +0 -0
  36. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/registries/device-types.md +0 -0
  37. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/src/span_panel_api_schema_1/__init__.py +0 -0
  38. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/src/span_panel_api_schema_1/catalog.py +0 -0
  39. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/src/span_panel_api_schema_1/firmware.py +0 -0
  40. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/src/span_panel_api_schema_1/py.typed +0 -0
  41. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/src/span_panel_api_schema_1/spec_lock.json +0 -0
@@ -16,6 +16,7 @@ xnotes/*
16
16
  .env
17
17
  .envrc
18
18
  .venv
19
+ .deps-installed
19
20
  .vscode/settings.json
20
21
 
21
22
  # mypy
@@ -9,6 +9,38 @@ number. A release here means this parser changed, never that the panel did.
9
9
 
10
10
  Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the last public release, never against the beta before it.
11
11
 
12
+ ## [1.4.0]
13
+
14
+ Requires `span-panel-api` **3.8.0 or newer**, whose snapshot fields accept the unknown values this parser now reports and carry the fields it now fills.
15
+
16
+ ### Added
17
+
18
+ - **spanos3/r202639/03 batch 2: reads more of the data this firmware publishes.**
19
+ - **`SchemaOneAdapter` exports the tree it holds, masked**, for `SpanMqttClient.masked_tree()`.
20
+ - **Each SPAN Drive's declared charge-current limit range is reported.**
21
+
22
+ ### Changed
23
+
24
+ - The shipped reference tree is recaptured with `ebus-panel-sim` 0.9.0: every circuit inside the panel's breaker positions, firmware r202639's discharge-positive battery meter, and no user charge limit until one is set.
25
+ - **`proximity_proven` is `None`**, where it was a hard-coded `True`, because the tree publishes no proximity property.
26
+ - **`uptime_s` is `None`**, where it was always `0`, because no device publishes an uptime and this parser keeps no readiness clock to measure one.
27
+
28
+ ### Removed
29
+
30
+ - **BREAKING FOR CONSUMERS: `snapshot.build_snapshot()` no longer takes the `ready_since` argument**, which no caller in this repository passed, so a call that passes it by keyword or position now raises `TypeError`.
31
+ - **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.
32
+
33
+ ### Fixed
34
+
35
+ - **A circuit whose shed priority is `UNKNOWN` is no longer reported as sheddable.**
36
+ - **The grid-forming device name falls back to vendor and model instead of showing a device id.**
37
+ - **A panel with no MID reports `dsm_state` `DSM_ON_GRID` and `current_run_config` `PANEL_ON_GRID` whenever it is publishing**, where both dropped to `UNKNOWN` each time `power-flows/grid` read within 1 W of zero
38
+ ([#215](https://github.com/SpanPanel/span-panel-api/issues/215)).
39
+ - **Each MQTT message costs the same few microseconds however many devices the panel declares**, where routing and readiness checks grew with the tree ([#223](https://github.com/SpanPanel/span-panel-api/issues/223)).
40
+ - **A property the panel clears reads as unpublished**, where it read as an empty value until the panel published it again.
41
+ - **A charge-current limit outside the range a SPAN Drive declares is refused** instead of published.
42
+ - **A device typed as a subtype of a class (`energy.ebus.device.bess.<x>`) is read as that class**, where it was dropped from the snapshot, adoption and discovery ([#228](https://github.com/SpanPanel/span-panel-api/issues/228)).
43
+
12
44
  ## [1.2.2]
13
45
 
14
46
  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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api-schema-1
3
- Version: 1.2.2
3
+ Version: 1.4.0
4
4
  Summary: Parent/child schema (data-model-version 1.x) parser for span-panel-api
5
5
  Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
6
6
  Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
@@ -8,7 +8,7 @@ Author: SpanPanel
8
8
  License-Expression: MIT
9
9
  Requires-Python: <4.0,>=3.14
10
10
  Requires-Dist: ebus-sdk<0.24,>=0.19.0
11
- Requires-Dist: span-panel-api<4.0,>=3.6.2
11
+ Requires-Dist: span-panel-api<4.0,>=3.8.0
12
12
  Description-Content-Type: text/markdown
13
13
 
14
14
  # span-panel-api-schema-1
@@ -45,6 +45,8 @@ pip install "span-panel-api[schema-0,schema-1]"
45
45
  - **Adopted devices** (`SpanPanelSnapshot.adopted_devices`). A device type this parser models nothing for is reported whole — identity and readings — rather than dropped. The schema is explicitly vendor-extensible, so an unmodelled device is an expected
46
46
  arrival rather than a hypothetical one.
47
47
  - **Extension properties** (`SpanPanelSnapshot.extension_properties`). A vendor property on a device this parser _does_ model, carried with its value and the snapshot subject it hangs off.
48
+ - **Meters outside the panel** (`SpanCircuitSnapshot.measures_outside_panel`). A circuit device that declares no breaker space is reported as a meter with no relay, breaker or shed priority, its power import-positive.
49
+ - **The masked device tree** (`SchemaOneAdapter.export_masked_tree()`). The tree the adapter holds with serials, device ids and site details masked, which `SpanMqttClient.masked_tree()` returns for a consumer's diagnostics.
48
50
 
49
51
  Devices are sorted by declared device type, never by device id. Field metadata comes from each device's own `$description` rather than from a schema document, because the same capability exposes different properties on different device classes — `meter` is
50
52
  voltage on the panel, power and energy on a circuit, and both currents on the lugs.
@@ -32,6 +32,8 @@ pip install "span-panel-api[schema-0,schema-1]"
32
32
  - **Adopted devices** (`SpanPanelSnapshot.adopted_devices`). A device type this parser models nothing for is reported whole — identity and readings — rather than dropped. The schema is explicitly vendor-extensible, so an unmodelled device is an expected
33
33
  arrival rather than a hypothetical one.
34
34
  - **Extension properties** (`SpanPanelSnapshot.extension_properties`). A vendor property on a device this parser _does_ model, carried with its value and the snapshot subject it hangs off.
35
+ - **Meters outside the panel** (`SpanCircuitSnapshot.measures_outside_panel`). A circuit device that declares no breaker space is reported as a meter with no relay, breaker or shed priority, its power import-positive.
36
+ - **The masked device tree** (`SchemaOneAdapter.export_masked_tree()`). The tree the adapter holds with serials, device ids and site details masked, which `SpanMqttClient.masked_tree()` returns for a consumer's diagnostics.
35
37
 
36
38
  Devices are sorted by declared device type, never by device id. Field metadata comes from each device's own `$description` rather than from a schema document, because the same capability exposes different properties on different device classes — `meter` is
37
39
  voltage on the panel, power and energy on a circuit, and both currents on the lugs.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api-schema-1"
3
- version = "1.2.2"
3
+ version = "1.4.0"
4
4
  description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -28,7 +28,13 @@ dependencies = [
28
28
  # Raised to 3.6.1, whose SpanPanelSnapshot.pv documents what this parser builds.
29
29
  # Raised to 3.6.2, which waits on this parser's missing labels after a bridge
30
30
  # rebuild as well as on connect; the fix spans both, so the pair moves together.
31
- "span-panel-api>=3.6.2,<4.0",
31
+ # Raised to 3.8.0, whose snapshot fields accept the unknown values this
32
+ # parser now reports: against an earlier one, the update times it leaves
33
+ # unset read as a constant 0. This parser also fills snapshot fields new in
34
+ # it, imports `device_id_order` from its models, implements the masked-tree
35
+ # protocol and types new in it, and relies on its client reading a cleared
36
+ # value as unpublished.
37
+ "span-panel-api>=3.8.0,<4.0",
32
38
  # Only this distribution depends on the eBus SDK. The bootstrap and
33
39
  # schema-0 stay clean, so a flat-panel install never pulls it in — which is
34
40
  # what bounds the release coupling this dependency introduces to panels on
@@ -17,9 +17,10 @@ tree, and the SDK repopulates — so there is no resync hook to wire or forget.
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ from importlib.metadata import PackageNotFoundError, version
20
21
  import logging
21
22
  import time
22
- from typing import TYPE_CHECKING, Final
23
+ from typing import TYPE_CHECKING, Final, NamedTuple
23
24
 
24
25
  from ebus_sdk import Controller
25
26
 
@@ -45,31 +46,92 @@ from span_panel_api_schema_1.const import (
45
46
  STATE_READY,
46
47
  TYPE_CIRCUIT,
47
48
  )
48
- from span_panel_api_schema_1.description import declared_settable, device_type, node_properties
49
+ from span_panel_api_schema_1.description import declared_settable, declares_class, device_type, node_properties
49
50
  from span_panel_api_schema_1.devices import feed_circuit_ids
50
51
  from span_panel_api_schema_1.field_metadata import build_field_metadata
51
- from span_panel_api_schema_1.panel import integer, text
52
+ from span_panel_api_schema_1.panel import integer, positions_outside_model, text
52
53
  from span_panel_api_schema_1.snapshot import TreeRoles, build_snapshot, harmonised_evse_keys
53
- from span_panel_api_schema_1.transport import ControllerRoutes
54
+ from span_panel_api_schema_1.transport import ControllerRoutes, control_target
55
+ from span_panel_api_schema_1.tree_export import export_masked_tree
54
56
 
55
57
  if TYPE_CHECKING:
56
58
  from collections.abc import Callable
57
59
 
58
60
  from ebus_sdk.homie import DiscoveredDevice
59
61
 
60
- from span_panel_api.models import FieldMetadata, SpanPanelSnapshot, V2HomieSchema
62
+ from span_panel_api.models import FieldMetadata, MaskedTree, SpanPanelSnapshot, V2HomieSchema
61
63
 
62
64
  _LOGGER = logging.getLogger(__name__)
63
65
 
66
+ _DISTRIBUTION: Final = "span-panel-api-schema-1"
67
+
68
+
69
+ def _producer() -> str:
70
+ """The distribution and version that wrote an export, or ``unknown`` where this code runs without its dist-info.
71
+
72
+ A checkout put on ``sys.path`` has no installed metadata, and an export must
73
+ not fail for the lack of a version string.
74
+ """
75
+ try:
76
+ return f"{_DISTRIBUTION} {version(_DISTRIBUTION)}"
77
+ except PackageNotFoundError:
78
+ return f"{_DISTRIBUTION} unknown"
79
+
80
+
64
81
  FEED_GRACE_S: Final = 2.0
65
- """How long an inverter's feed may stay unvalued once everything else has arrived.
82
+ """How long an inverter's feed or a DER's model may stay unvalued once every name has arrived.
66
83
 
67
- A retained `connection/feeds-device-id` lands in the same burst as its circuit's
68
- name. One still missing this long after the rest of the tree's labels is taken
69
- as absent: a DER may be published with no connection record at all.
84
+ A retained `connection/feeds-device-id` or `info/model` lands in the same burst
85
+ as the rest of the tree's labels. One still missing this long after every name
86
+ and firmware version is taken as absent: a DER may be published with no
87
+ connection record at all, and a declared model may never be valued.
70
88
  """
71
89
 
72
90
 
91
+ class _ValueTopic(NamedTuple):
92
+ """One device's `node/property`, and whether the topic carries its value or its `$target`."""
93
+
94
+ device_id: str
95
+ node_id: str
96
+ property_id: str
97
+ target: bool
98
+
99
+
100
+ def _value_topic(topic: str) -> _ValueTopic | None:
101
+ """The value or `$target` a topic addresses, or None for any other topic.
102
+
103
+ The two shapes `Controller` stores a payload under, read as it reads them:
104
+ `<domain>/5/<device>/<node>/<property>` is a value unless the property id
105
+ starts with `$`, and `<domain>/5/<device>/<node>/<property>/$target` is a
106
+ target. `$state`, `$description` and every other shape are not values.
107
+ """
108
+ parts = topic.split("/")
109
+ if parts[:2] != [HOMIE_DOMAIN, HOMIE_VERSION]:
110
+ return None
111
+ if len(parts) == 5 and not parts[4].startswith("$"):
112
+ return _ValueTopic(parts[2], parts[3], parts[4], target=False)
113
+ if len(parts) == 6 and parts[5] == "$target":
114
+ return _ValueTopic(parts[2], parts[3], parts[4], target=True)
115
+ return None
116
+
117
+
118
+ def _forget(by_node: object, node_id: str, property_id: str) -> bool:
119
+ """Remove one value from an SDK `{node: {property: value}}` map; whether there was one.
120
+
121
+ A node left with no values is removed with its last one, so the map holds
122
+ what the broker retains and nothing more.
123
+ """
124
+ if not isinstance(by_node, dict):
125
+ return False
126
+ by_property: object = by_node.get(node_id)
127
+ if not isinstance(by_property, dict) or property_id not in by_property:
128
+ return False
129
+ del by_property[property_id]
130
+ if not by_property:
131
+ del by_node[node_id]
132
+ return True
133
+
134
+
73
135
  def _unvalued_feeds_for_unplaced_pvs(roles: TreeRoles) -> list[str]:
74
136
  """Circuits whose feed has not arrived, while an inverter has no known source.
75
137
 
@@ -110,13 +172,21 @@ class SchemaOneAdapter:
110
172
  self._serial_number = serial_number
111
173
  self._schema = schema
112
174
  self._clock = clock
113
- self._feed_wait_since: float | None = None
114
- self._feed_wait_reported = False
175
+ self._grace_since: dict[str, float] = {}
176
+ self._grace_reported: set[str] = set()
177
+ self._positions_reported = False
115
178
  self._routes = ControllerRoutes()
116
179
  self._controller = Controller(root_device_id=serial_number, mqttc=self._routes)
117
180
  self._property_callbacks: list[Callable[[str, str, str, str | None], None]] = []
118
181
  self._awaiting: tuple[str, ...] = ()
182
+ # Readiness as last computed, or None once the tree has changed since.
183
+ self._ready: bool | None = None
119
184
  self._controller.set_on_property_changed_callback(self._on_property_changed)
185
+ # Every event that can change what `is_ready()` answers; see there.
186
+ self._controller.set_on_device_discovered_callback(self._tree_changed)
187
+ self._controller.set_on_device_state_changed_callback(self._tree_changed)
188
+ self._controller.set_on_device_removed_callback(self._tree_changed)
189
+ self._controller.set_on_description_received_callback(self._tree_changed)
120
190
  # Records the subscriptions the tree walk needs; nothing reaches the
121
191
  # wire, because this object has no connection to reach it with.
122
192
  self._controller.start_discovery()
@@ -136,8 +206,48 @@ class SchemaOneAdapter:
136
206
  return [f"{HOMIE_DOMAIN}/{HOMIE_VERSION}/#"]
137
207
 
138
208
  def handle_message(self, topic: str, payload: str) -> None:
209
+ """Route one message to the SDK callback that asked for it, keeping a clear a clear.
210
+
211
+ A zero-length payload deletes a retained message, and Homie 5 spells an
212
+ empty string differently, as one `0x00` byte, so that the two stay
213
+ apart. The SDK decodes both to `""` and stores it, which would leave a
214
+ property the panel cleared reading as one it published empty. So a
215
+ clear of a value or `$target` the tree holds is applied here: the
216
+ stored value goes, exactly as an unpublished one is absent, and
217
+ property observers hear `None`. A clear of `$state` (the SDK's device
218
+ removal) or `$description` still goes to the SDK, as does anything
219
+ else. A `$description` cleared on its own makes the SDK log
220
+ `descriptionParseError` at ERROR and keep the stale description; that
221
+ is normally silent only because Homie clears `$state` first, after
222
+ which the SDK no longer holds the device and ignores the rest.
223
+ """
224
+ if not payload and self._clear(topic):
225
+ return
139
226
  self._routes.dispatch(topic, payload)
140
227
 
228
+ def _clear(self, topic: str) -> bool:
229
+ """Apply a clear of a value or target; False where the topic addresses none the tree holds.
230
+
231
+ Only a device the controller holds is touched, the same test the SDK
232
+ applies before storing a value. Any other clear goes on to dispatch,
233
+ where a route the SDK still records for a device it dropped ignores it,
234
+ and an unrouted one takes back what is held for its topic.
235
+
236
+ A clear of a value that was never published changes nothing and is not
237
+ reported. A cleared value is reported as a change, so it forgets the
238
+ remembered readiness where a new value would; a cleared target is not,
239
+ as no target change is.
240
+ """
241
+ address = _value_topic(topic)
242
+ device = None if address is None else self._controller.get_device(address.device_id)
243
+ if address is None or device is None:
244
+ return False
245
+ if address.target:
246
+ _forget(device.property_targets, address.node_id, address.property_id)
247
+ elif _forget(device.properties, address.node_id, address.property_id):
248
+ self._notify(address.device_id, address.node_id, address.property_id, None)
249
+ return True
250
+
141
251
  def is_ready(self) -> bool:
142
252
  """Ready when the whole declared tree has described itself.
143
253
 
@@ -167,11 +277,26 @@ class SchemaOneAdapter:
167
277
 
168
278
  The model is required only when the root's description declares it: the
169
279
  panel's size comes from nowhere else, and a snapshot built a moment too
170
- early reports zero spaces, which erases every unmapped position rather
171
- than merely mis-stating a number. Asking only for what the panel itself
172
- promised keeps a firmware that omits the property connectable — it
173
- falls back to the drift warning in `panel_size_from_model`.
280
+ early reports zero spaces, which leaves a consumer laying out the
281
+ panel's positions with none to lay out. Asking only for what the panel
282
+ itself promised keeps a firmware that omits the property connectable —
283
+ it falls back to the drift warning in `panel_size_from_model`.
284
+
285
+ **The answer is remembered until the tree changes.** The transport asks
286
+ before and after every message, and walking the tree each time cost
287
+ more than routing the message did (#223). Only the Controller's own
288
+ events can move it: a device's first `$state`, a change of state, a
289
+ removal, a `$description`, and the root's `info/model` value. Each
290
+ forgets the answer and the next call works it out again. The events
291
+ never work it out themselves, because a description is announced before
292
+ the SDK has reconciled the children it declares.
174
293
  """
294
+ if self._ready is None:
295
+ self._ready = self._tree_ready()
296
+ return self._ready
297
+
298
+ def _tree_ready(self) -> bool:
299
+ """What `is_ready()` answers, worked out from the tree as it stands."""
175
300
  root = self._controller.get_root(self._serial_number)
176
301
  if root is None or root.state != STATE_READY or not root.description:
177
302
  return False
@@ -186,7 +311,34 @@ class SchemaOneAdapter:
186
311
 
187
312
  def build_snapshot(self) -> SpanPanelSnapshot:
188
313
  root = self._require_root()
189
- return build_snapshot(root, self._children())
314
+ snapshot = build_snapshot(root, self._children())
315
+ self._report_positions_outside_model(snapshot)
316
+ return snapshot
317
+
318
+ def _report_positions_outside_model(self, snapshot: SpanPanelSnapshot) -> None:
319
+ """Warn once per panel when its circuits occupy spaces outside its model's range.
320
+
321
+ The snapshot keeps reporting the model's range, so such a circuit lies
322
+ outside the positions a consumer lays out until the table is corrected.
323
+ """
324
+ if self._positions_reported:
325
+ return
326
+ outside = positions_outside_model(
327
+ snapshot.model, (tab for circuit in snapshot.circuits.values() for tab in circuit.tabs)
328
+ )
329
+ if outside is None:
330
+ return
331
+ (first, last), (lowest, highest) = outside
332
+ _LOGGER.warning(
333
+ "Panel model %s has breaker positions %d-%d, but its circuits occupy spaces %d-%d; "
334
+ "the snapshot reports the model's positions",
335
+ snapshot.model,
336
+ first,
337
+ last,
338
+ lowest,
339
+ highest,
340
+ )
341
+ self._positions_reported = True
190
342
 
191
343
  def build_field_metadata(self) -> dict[str, FieldMetadata]:
192
344
  root = self._controller.get_root(self._serial_number)
@@ -212,6 +364,12 @@ class SchemaOneAdapter:
212
364
  the same exposure, so a DER missing the model it declared is reported
213
365
  alongside a circuit missing its name.
214
366
 
367
+ Only a declared label is waited for. A circuit-typed device whose
368
+ description declares no `info/name` -- a meter outside the panel
369
+ declares no `info` node at all -- has no name coming, and waiting for
370
+ one would hold every connect and every tree rebuild to the full
371
+ timeout.
372
+
215
373
  An inverter's feeding circuit is waited on too, because it decides the
216
374
  inverter's `pv_inverters` key, and a key that moved after the first
217
375
  snapshot would move every entity built on it. A circuit feeding nothing
@@ -223,52 +381,66 @@ class SchemaOneAdapter:
223
381
  The panel itself is reported while a declared BESS waits on the
224
382
  panel's declared firmware version, which decides the BESS meter's frame.
225
383
 
226
- The feed wait is bounded by `FEED_GRACE_S`. A DER may be published with
227
- no connection record pointing at it, and then no value is coming; a
228
- retained feed lands in the same burst as its circuit's name, so once
229
- every name, model and firmware version has arrived, a feed still
230
- unvalued after the grace is taken as absent. The inverter is then keyed
231
- by its device id, reported once at INFO rather than the transport
232
- warning after the full name timeout. The grace restarts whenever
233
- anything else is missing again, and once every inverter is placed, so
234
- an inverter that appears after another's feed has arrived, within the
235
- same wait, gets a whole grace of its own.
384
+ The feed and model waits are bounded by `FEED_GRACE_S`. A DER may be
385
+ published with no connection record pointing at it, and a declared
386
+ model may never be valued; then no value is coming. Both land in the
387
+ same burst as the circuits' names, so once every name and firmware
388
+ version has arrived, a feed or model still unvalued after the grace is
389
+ taken as absent and reported once per device at INFO, rather than the
390
+ transport warning after the full name timeout. An inverter whose feed
391
+ was taken as absent is keyed by its device id; a model fills device
392
+ metadata only, so one that lands later moves nothing. Each device's
393
+ grace starts when it is first waited on alone and restarts whenever a
394
+ name or firmware version is missing again, so a device that appears
395
+ after another's grace has run gets a whole grace of its own.
236
396
  """
237
397
  roles = TreeRoles(self._children())
238
- missing = [circuit.device_id for circuit in roles.circuits if not circuit.get_property(NODE_INFO, PROP_NAME)]
398
+ missing = [
399
+ circuit.device_id
400
+ for circuit in roles.circuits
401
+ if PROP_NAME in circuit.get_node_properties(NODE_INFO) and not circuit.get_property(NODE_INFO, PROP_NAME)
402
+ ]
403
+ missing.extend(self._firmware_version_pending(roles))
239
404
  ders = (roles.bess, *roles.pvs, *roles.evse)
240
- missing.extend(
241
- device.device_id
405
+ graced = {
406
+ device.device_id: f"{NODE_INFO}/{PROP_MODEL}"
242
407
  for device in ders
243
408
  if device is not None
244
409
  and PROP_MODEL in device.get_node_properties(NODE_INFO)
245
410
  and device.get_property(NODE_INFO, PROP_MODEL) is None
411
+ }
412
+ graced.update(
413
+ (circuit_id, f"{NODE_CONNECTION}/{PROP_FEEDS_DEVICE_ID}")
414
+ for circuit_id in _unvalued_feeds_for_unplaced_pvs(roles)
246
415
  )
247
- missing.extend(self._firmware_version_pending(roles))
248
- feeds = _unvalued_feeds_for_unplaced_pvs(roles)
249
- if feeds and not missing:
250
- now = self._clock()
251
- if self._feed_wait_since is None:
252
- self._feed_wait_since = now
253
- if now - self._feed_wait_since >= FEED_GRACE_S:
254
- if not self._feed_wait_reported:
255
- _LOGGER.info(
256
- "No connection record names %d circuit(s) as an inverter's feed; "
257
- "inverters without one are keyed by device id",
258
- len(feeds),
259
- )
260
- self._feed_wait_reported = True
261
- feeds = []
262
- else:
263
- # No feed is being waited out on its own: something else is still
264
- # missing, or every inverter is placed. Either way a later feed wait
265
- # is a new one and gets the whole grace.
266
- self._feed_wait_since = None
267
- if not feeds:
268
- self._feed_wait_reported = False
269
- missing.extend(feeds)
416
+ missing.extend(self._within_grace(graced, held=bool(missing)))
270
417
  return missing
271
418
 
419
+ def _within_grace(self, pending: dict[str, str], *, held: bool) -> list[str]:
420
+ """The devices of `pending` whose grace has not run out.
421
+
422
+ `pending` maps each device to the declared `node/property` it has not
423
+ valued. While `held`, something without a grace is still missing, so
424
+ every device is waited on and no grace runs. A device whose grace runs
425
+ out is reported once, and stops being waited on until its value arrives
426
+ or a hold starts its grace again.
427
+ """
428
+ for device_id in self._grace_since.keys() - pending.keys():
429
+ del self._grace_since[device_id]
430
+ self._grace_reported &= pending.keys()
431
+ if held:
432
+ self._grace_since.clear()
433
+ return list(pending)
434
+ now = self._clock()
435
+ waiting: list[str] = []
436
+ for device_id, path in pending.items():
437
+ if now - self._grace_since.setdefault(device_id, now) < FEED_GRACE_S:
438
+ waiting.append(device_id)
439
+ elif device_id not in self._grace_reported:
440
+ _LOGGER.info("%s declares %s and has not published it; taking it as absent", device_id, path)
441
+ self._grace_reported.add(device_id)
442
+ return waiting
443
+
272
444
  def _firmware_version_pending(self, roles: TreeRoles) -> list[str]:
273
445
  """The panel itself, while a declared BESS waits on the panel's firmware version.
274
446
 
@@ -289,13 +461,15 @@ class SchemaOneAdapter:
289
461
  return []
290
462
 
291
463
  def find_node_by_type(self, type_str: str) -> str | None:
292
- """Return the id of the first device declaring `type_str`.
464
+ """Return the id of the first device declaring `type_str` or a subtype of it.
293
465
 
294
466
  Named for the flat schema's nodes; under parent/child the same question
295
- is asked of devices, and the answer is a device id.
467
+ is asked of devices, and the answer is a device id. A device typed
468
+ ``…device.bess.hybrid`` is a ``…device.bess`` (`declares_class`), the
469
+ same device the snapshot reads as the battery.
296
470
  """
297
471
  for device in self._children():
298
- if device_type(device) == type_str:
472
+ if declares_class(device_type(device), type_str):
299
473
  return device.device_id
300
474
  return None
301
475
 
@@ -308,7 +482,7 @@ class SchemaOneAdapter:
308
482
  """Where this circuit's relay is commanded, or None if it may not be.
309
483
 
310
484
  **None where the panel declares the relay non-commandable**, by either of
311
- the two signals `relay_is_settable` reads. `_target` is pure string
485
+ the two signals `relay_is_settable` reads. `control_target` is pure string
312
486
  formatting from a device id, so building the topic unconditionally aimed
313
487
  a write at a circuit the panel commissioned as always-on — every part of
314
488
  the refusal was already in the tree, and nothing consulted it.
@@ -325,7 +499,7 @@ class SchemaOneAdapter:
325
499
  device = self._child(circuit_id)
326
500
  if device is None or not relay_is_settable(device):
327
501
  return None
328
- return self._target(circuit_id, NODE_SWITCH, PROP_RELAY)
502
+ return control_target(circuit_id, NODE_SWITCH, PROP_RELAY)
329
503
 
330
504
  def set_circuit_priority_target(self, circuit_id: str) -> ControlTarget | None:
331
505
  """Where this circuit's shed priority is written, or None if it may not be.
@@ -342,7 +516,7 @@ class SchemaOneAdapter:
342
516
  device = self._child(circuit_id)
343
517
  if device is None or not priority_is_settable(device):
344
518
  return None
345
- return self._target(circuit_id, NODE_LOAD_SHED, PROP_PRIORITY)
519
+ return control_target(circuit_id, NODE_LOAD_SHED, PROP_PRIORITY)
346
520
 
347
521
  def has_circuit(self, circuit_id: str) -> bool:
348
522
  """Whether the tree carries a circuit under this id.
@@ -351,7 +525,9 @@ class SchemaOneAdapter:
351
525
  is addressable by id — the BESS, the MID, the lugs, a charger — and none
352
526
  of them is a circuit, so answering on membership alone would report a
353
527
  BESS as a circuit whose relay the panel declares non-commandable. It
354
- declares no relay at all.
528
+ declares no relay at all. The class is the answer rather than the exact
529
+ type, so a subtyped circuit the snapshot reads into `circuits` is a
530
+ circuit here too.
355
531
 
356
532
  A child that has not described itself yet reads as absent, because it
357
533
  has declared nothing: type, nodes and properties all arrive together in
@@ -364,7 +540,7 @@ class SchemaOneAdapter:
364
540
  would put a second, weaker rule where the authorisation lives.
365
541
  """
366
542
  device = self._child(circuit_id)
367
- return device is not None and device_type(device) == TYPE_CIRCUIT
543
+ return device is not None and declares_class(device_type(device), TYPE_CIRCUIT)
368
544
 
369
545
  def set_dominant_power_source_target(self) -> ControlTarget | None:
370
546
  """The settable successor: `shed/asserted-islanding-state` on the panel.
@@ -398,7 +574,7 @@ class SchemaOneAdapter:
398
574
  root = self._controller.get_root(self._serial_number)
399
575
  if not declared_settable(node_properties(root, NODE_SHED).get(PROP_ASSERTED_ISLANDING_STATE)):
400
576
  return None
401
- return self._target(self._serial_number, NODE_SHED, PROP_ASSERTED_ISLANDING_STATE)
577
+ return control_target(self._serial_number, NODE_SHED, PROP_ASSERTED_ISLANDING_STATE)
402
578
 
403
579
  def dominant_power_source_payload(self, value: str) -> str | None:
404
580
  """Translate a flat `dominant-power-source` value into an assertion.
@@ -447,7 +623,7 @@ class SchemaOneAdapter:
447
623
  if writable is None:
448
624
  return None
449
625
  device, surface, limit = writable
450
- return self._target(device.device_id, surface.node, limit.property_id)
626
+ return control_target(device.device_id, surface.node, limit.property_id)
451
627
 
452
628
  def evse_charge_limit_payload(self, node_id: str, amps: int) -> str | None:
453
629
  """The payload to publish for `amps`, or None if it may not be published.
@@ -463,22 +639,39 @@ class SchemaOneAdapter:
463
639
  charge-only EVSE cannot be told to export by lowering a ceiling, so a
464
640
  negative value is not a smaller limit but a malformed one.
465
641
 
642
+ **Refuses outside the range the limit declares.** Firmware r202639
643
+ publishes `<min>:<commissioned maximum>` as the limit's `$format`
644
+ (`6:32`), so a value under the minimum is refused here instead of being
645
+ published for the panel to reject or misapply. A declared maximum
646
+ tighter than the ceiling applies too. A format that is absent, or that
647
+ cannot be read as an integer range, bounds nothing extra and the
648
+ ceiling rule above still holds.
649
+
466
650
  A charger that declares no ceiling is not second-guessed — the catalog
467
- makes `installer-max` a SHOULD, and the value that bounds the write is
468
- the one the panel published, not one this library invents.
651
+ makes `installer-max` a SHOULD, and the values that bound the write are
652
+ the ones the panel published, not ones this library invents. With no
653
+ ceiling, a maximum declared in the limit's `$format` still bounds the
654
+ write; with neither, nothing does.
469
655
  """
470
656
  writable = self._writable_charge_limit(node_id)
471
657
  if writable is None or amps < 0:
472
658
  return None
473
- device, surface, _limit = writable
659
+ device, surface, limit = writable
474
660
  ceiling = surface.ceiling
475
661
  commissioned = None if ceiling is None else integer(device, surface.node, ceiling.property_id)
476
662
  if commissioned is not None and amps > commissioned:
477
663
  return None
664
+ if limit.bounds is not None:
665
+ minimum, maximum = limit.bounds
666
+ if (minimum is not None and amps < minimum) or (maximum is not None and amps > maximum):
667
+ return None
478
668
  return str(amps)
479
669
 
480
670
  def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]:
481
- """Subscribe to per-property updates: `(device_id, node_id, property_id, value)`."""
671
+ """Subscribe to per-property updates: `(device_id, node_id, property_id, value)`.
672
+
673
+ `value` is `None` when the panel clears a value it had published.
674
+ """
482
675
  self._property_callbacks.append(callback)
483
676
 
484
677
  def _unregister() -> None:
@@ -514,23 +707,21 @@ class SchemaOneAdapter:
514
707
  return device, surface, surface.limit
515
708
  return None
516
709
 
517
- def _target(self, device_id: str, node: str, prop: str) -> ControlTarget:
518
- """One device/node/property address as both a set topic and an observation key.
710
+ def export_masked_tree(self) -> MaskedTree | None:
711
+ """The device tree the adapter holds, masked, or None before the root has described itself.
519
712
 
520
- The triple is returned alongside the topic rather than left for the
521
- transport to parse back out of it: the transport is the one component
522
- that is supposed to know nothing about this schema's topic grammar, and
523
- under parent/child the device is a peer of the panel rather than a node
524
- beneath it, so the grammar is not even the flat one.
525
-
526
- The spelling here is the spelling `_on_property_changed` reports under,
527
- because a write is verified by matching one against the other.
713
+ Computed at call time and not gated on `is_ready()`: a tree that has not
714
+ finished arriving is exactly when a diagnostic is wanted, and
715
+ `metadata["tree_complete"]` says whether this one is whole.
528
716
  """
529
- return ControlTarget(
530
- topic=f"{HOMIE_DOMAIN}/{HOMIE_VERSION}/{device_id}/{node}/{prop}/set",
531
- device_id=device_id,
532
- node_id=node,
533
- property_id=prop,
717
+ root = self._controller.get_root(self._serial_number)
718
+ if root is None or root.description is None:
719
+ return None
720
+ return export_masked_tree(
721
+ root,
722
+ self._children(),
723
+ producer=_producer(),
724
+ tree_complete=self._controller.is_tree_complete(self._serial_number),
534
725
  )
535
726
 
536
727
  def _require_root(self) -> DiscoveredDevice:
@@ -596,5 +787,16 @@ class SchemaOneAdapter:
596
787
  protocol has no place for the previous value, and consumers that need
597
788
  one keep it themselves.
598
789
  """
790
+ self._notify(device_id, node_id, property_id, value)
791
+
792
+ def _notify(self, device_id: str, node_id: str, property_id: str, value: str | None) -> None:
793
+ """Report one property's new value, `None` once cleared, after forgetting what it invalidates."""
794
+ # The one value readiness waits on.
795
+ if (device_id, node_id, property_id) == (self._serial_number, NODE_INFO, PROP_MODEL):
796
+ self._ready = None
599
797
  for callback in list(self._property_callbacks):
600
798
  callback(device_id, node_id, property_id, value)
799
+
800
+ def _tree_changed(self, *_event: object) -> None:
801
+ """Forget the remembered readiness. Accepts any of the Controller's tree events."""
802
+ self._ready = None