span-panel-api-schema-1 1.4.0b2__tar.gz → 1.4.0b4__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.4.0b2 → span_panel_api_schema_1-1.4.0b4}/.gitignore +1 -0
  2. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/CHANGELOG.md +8 -2
  3. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/PKG-INFO +2 -2
  4. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/pyproject.toml +4 -2
  5. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/src/span_panel_api_schema_1/adapter.py +184 -12
  6. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/src/span_panel_api_schema_1/adoption.py +12 -4
  7. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/src/span_panel_api_schema_1/charge_limit.py +42 -1
  8. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/src/span_panel_api_schema_1/description.py +15 -0
  9. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/src/span_panel_api_schema_1/devices.py +25 -7
  10. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/src/span_panel_api_schema_1/field_metadata.py +16 -14
  11. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/src/span_panel_api_schema_1/panel.py +7 -6
  12. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/src/span_panel_api_schema_1/reference/parent_child_tree.json +15 -17
  13. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/src/span_panel_api_schema_1/snapshot.py +15 -12
  14. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/src/span_panel_api_schema_1/transport.py +62 -20
  15. span_panel_api_schema_1-1.4.0b4/src/span_panel_api_schema_1/tree_export.py +924 -0
  16. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/README.md +0 -0
  17. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/breaker.json +0 -0
  18. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/charge-limit.json +0 -0
  19. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/connection.json +0 -0
  20. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/door.json +0 -0
  21. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/grid-forming.json +0 -0
  22. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/grid.json +0 -0
  23. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/info.json +0 -0
  24. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/load-shed.json +0 -0
  25. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/meter.json +0 -0
  26. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/pcs.json +0 -0
  27. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/power-flows.json +0 -0
  28. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/shed-forecast.json +0 -0
  29. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/shed.json +0 -0
  30. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/soc.json +0 -0
  31. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/status.json +0 -0
  32. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/switch.json +0 -0
  33. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/registries/device-types.md +0 -0
  34. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/src/span_panel_api_schema_1/__init__.py +0 -0
  35. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/src/span_panel_api_schema_1/catalog.py +0 -0
  36. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/src/span_panel_api_schema_1/circuits.py +0 -0
  37. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/src/span_panel_api_schema_1/const.py +0 -0
  38. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/src/span_panel_api_schema_1/extension.py +0 -0
  39. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/src/span_panel_api_schema_1/firmware.py +0 -0
  40. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/src/span_panel_api_schema_1/py.typed +0 -0
  41. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/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
@@ -16,17 +16,18 @@ Requires `span-panel-api` **3.8.0 or newer**, whose snapshot fields accept the u
16
16
  ### Added
17
17
 
18
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.**
19
21
 
20
22
  ### Changed
21
23
 
22
- - The shipped reference tree is recaptured with every circuit inside the panel's breaker positions.
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.
23
25
  - **`proximity_proven` is `None`**, where it was a hard-coded `True`, because the tree publishes no proximity property.
24
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.
25
27
 
26
28
  ### Removed
27
29
 
28
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`.
29
-
30
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.
31
32
 
32
33
  ### Fixed
@@ -35,6 +36,11 @@ Requires `span-panel-api` **3.8.0 or newer**, whose snapshot fields accept the u
35
36
  - **The grid-forming device name falls back to vendor and model instead of showing a device id.**
36
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
37
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 it tested every recorded subscription in turn and every readiness check walked the device tree. On a 30-device tree the work per message falls
40
+ from 90–250 µs to about 2 µs, measured on a desktop machine ([#223](https://github.com/SpanPanel/span-panel-api/issues/223)).
41
+ - **A property the panel clears reads as unpublished**, where it read as an empty value until the panel published it again.
42
+ - **A charge-current limit outside the range a SPAN Drive declares is refused** instead of published.
43
+ - **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)).
38
44
 
39
45
  ## [1.2.2]
40
46
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api-schema-1
3
- Version: 1.4.0b2
3
+ Version: 1.4.0b4
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.8.0b1
11
+ Requires-Dist: span-panel-api<4.0,>=3.8.0b2
12
12
  Description-Content-Type: text/markdown
13
13
 
14
14
  # span-panel-api-schema-1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api-schema-1"
3
- version = "1.4.0b2"
3
+ version = "1.4.0b4"
4
4
  description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -33,7 +33,9 @@ dependencies = [
33
33
  # unset read as a constant 0.
34
34
  # Raised to 3.8.0b1: this parser fills snapshot fields new in it and imports
35
35
  # `device_id_order` from its models.
36
- "span-panel-api>=3.8.0b1,<4.0",
36
+ # Raised to 3.8.0b2: this parser implements the masked-tree protocol and types
37
+ # new in it, and relies on its client reading a cleared value as unpublished.
38
+ "span-panel-api>=3.8.0b2,<4.0",
37
39
  # Only this distribution depends on the eBus SDK. The bootstrap and
38
40
  # schema-0 stay clean, so a flat-panel install never pulls it in — which is
39
41
  # 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,22 +46,38 @@ 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
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
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
82
  """How long an inverter's feed or a DER's model may stay unvalued once every name has arrived.
66
83
 
@@ -71,6 +88,50 @@ connection record at all, and a declared model may never be valued.
71
88
  """
72
89
 
73
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
+
74
135
  def _unvalued_feeds_for_unplaced_pvs(roles: TreeRoles) -> list[str]:
75
136
  """Circuits whose feed has not arrived, while an inverter has no known source.
76
137
 
@@ -118,7 +179,14 @@ class SchemaOneAdapter:
118
179
  self._controller = Controller(root_device_id=serial_number, mqttc=self._routes)
119
180
  self._property_callbacks: list[Callable[[str, str, str, str | None], None]] = []
120
181
  self._awaiting: tuple[str, ...] = ()
182
+ # Readiness as last computed, or None once the tree has changed since.
183
+ self._ready: bool | None = None
121
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)
122
190
  # Records the subscriptions the tree walk needs; nothing reaches the
123
191
  # wire, because this object has no connection to reach it with.
124
192
  self._controller.start_discovery()
@@ -138,8 +206,48 @@ class SchemaOneAdapter:
138
206
  return [f"{HOMIE_DOMAIN}/{HOMIE_VERSION}/#"]
139
207
 
140
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
141
226
  self._routes.dispatch(topic, payload)
142
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
+
143
251
  def is_ready(self) -> bool:
144
252
  """Ready when the whole declared tree has described itself.
145
253
 
@@ -173,7 +281,22 @@ class SchemaOneAdapter:
173
281
  panel's positions with none to lay out. Asking only for what the panel
174
282
  itself promised keeps a firmware that omits the property connectable —
175
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.
176
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."""
177
300
  root = self._controller.get_root(self._serial_number)
178
301
  if root is None or root.state != STATE_READY or not root.description:
179
302
  return False
@@ -338,13 +461,15 @@ class SchemaOneAdapter:
338
461
  return []
339
462
 
340
463
  def find_node_by_type(self, type_str: str) -> str | None:
341
- """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.
342
465
 
343
466
  Named for the flat schema's nodes; under parent/child the same question
344
- 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.
345
470
  """
346
471
  for device in self._children():
347
- if device_type(device) == type_str:
472
+ if declares_class(device_type(device), type_str):
348
473
  return device.device_id
349
474
  return None
350
475
 
@@ -400,7 +525,9 @@ class SchemaOneAdapter:
400
525
  is addressable by id — the BESS, the MID, the lugs, a charger — and none
401
526
  of them is a circuit, so answering on membership alone would report a
402
527
  BESS as a circuit whose relay the panel declares non-commandable. It
403
- 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.
404
531
 
405
532
  A child that has not described itself yet reads as absent, because it
406
533
  has declared nothing: type, nodes and properties all arrive together in
@@ -413,7 +540,7 @@ class SchemaOneAdapter:
413
540
  would put a second, weaker rule where the authorisation lives.
414
541
  """
415
542
  device = self._child(circuit_id)
416
- 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)
417
544
 
418
545
  def set_dominant_power_source_target(self) -> ControlTarget | None:
419
546
  """The settable successor: `shed/asserted-islanding-state` on the panel.
@@ -512,22 +639,39 @@ class SchemaOneAdapter:
512
639
  charge-only EVSE cannot be told to export by lowering a ceiling, so a
513
640
  negative value is not a smaller limit but a malformed one.
514
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
+
515
650
  A charger that declares no ceiling is not second-guessed — the catalog
516
- makes `installer-max` a SHOULD, and the value that bounds the write is
517
- 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.
518
655
  """
519
656
  writable = self._writable_charge_limit(node_id)
520
657
  if writable is None or amps < 0:
521
658
  return None
522
- device, surface, _limit = writable
659
+ device, surface, limit = writable
523
660
  ceiling = surface.ceiling
524
661
  commissioned = None if ceiling is None else integer(device, surface.node, ceiling.property_id)
525
662
  if commissioned is not None and amps > commissioned:
526
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
527
668
  return str(amps)
528
669
 
529
670
  def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]:
530
- """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
+ """
531
675
  self._property_callbacks.append(callback)
532
676
 
533
677
  def _unregister() -> None:
@@ -563,6 +707,23 @@ class SchemaOneAdapter:
563
707
  return device, surface, surface.limit
564
708
  return None
565
709
 
710
+ def export_masked_tree(self) -> MaskedTree | None:
711
+ """The device tree the adapter holds, masked, or None before the root has described itself.
712
+
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.
716
+ """
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),
725
+ )
726
+
566
727
  def _require_root(self) -> DiscoveredDevice:
567
728
  """The root, or a clear error if discovery has not finished.
568
729
 
@@ -626,5 +787,16 @@ class SchemaOneAdapter:
626
787
  protocol has no place for the previous value, and consumers that need
627
788
  one keep it themselves.
628
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
629
797
  for callback in list(self._property_callbacks):
630
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
@@ -35,7 +35,14 @@ from span_panel_api_schema_1.const import (
35
35
  TYPE_PANEL,
36
36
  TYPE_PV,
37
37
  )
38
- from span_panel_api_schema_1.description import declared_settable, device_type, nodes, optional_str, properties
38
+ from span_panel_api_schema_1.description import (
39
+ declared_settable,
40
+ declares_class,
41
+ device_type,
42
+ nodes,
43
+ optional_str,
44
+ properties,
45
+ )
39
46
 
40
47
  if TYPE_CHECKING:
41
48
  from ebus_sdk.homie import DiscoveredDevice
@@ -72,10 +79,11 @@ def is_modelled(declared: str) -> bool:
72
79
  Subtype-aware, because firmware may declare either a base type or a subtype
73
80
  of it -- ``…device.lugs`` with a ``direction`` property, or
74
81
  ``…device.lugs.upstream``. A subtype of something modelled is modelled: the
75
- snapshot builder matches lugs by prefix for exactly this reason, and a
76
- subtype arriving must not be adopted behind the builder's back.
82
+ snapshot builder sorts each class with the same `declares_class`, so the
83
+ two agree about which devices a class covers, and a subtype the builder
84
+ reads is never adopted behind its back.
77
85
  """
78
- return any(declared == known or declared.startswith(f"{known}.") for known in MODELLED_TYPES)
86
+ return any(declares_class(declared, known) for known in MODELLED_TYPES)
79
87
 
80
88
 
81
89
  def build_adopted_devices(children: list[DiscoveredDevice]) -> tuple[AdoptedDevice, ...]:
@@ -39,6 +39,7 @@ Homie 5's default rather than a rule this module chose, and it is the same one
39
39
  from __future__ import annotations
40
40
 
41
41
  from dataclasses import dataclass
42
+ import re
42
43
  from typing import TYPE_CHECKING
43
44
 
44
45
  from span_panel_api_schema_1.description import declared_settable, nodes, optional_str, properties
@@ -81,6 +82,42 @@ mappings.
81
82
  """
82
83
 
83
84
 
85
+ _PLAIN_INTEGER = re.compile(r"-?[0-9]+", re.ASCII)
86
+
87
+ IntegerBounds = tuple[int | None, int | None]
88
+ """A declared integer range, `(minimum, maximum)`; None on a side the format leaves open."""
89
+
90
+
91
+ def declared_integer_bounds(datatype: str, declared: object) -> IntegerBounds | None:
92
+ """The range an `integer` property's `$format` declares, or None.
93
+
94
+ Homie 5 spells it `<min>:<max>` and allows either side to be empty. Anything
95
+ else is not interpreted: a format that is not a string, a datatype that is
96
+ not `integer`, a missing colon, extra segments, or a bound that is not a
97
+ plain integer (an optional minus sign and ASCII digits, so `1_0`, `+6` and
98
+ non-ASCII digits are refused) or a minimum above the maximum all yield None,
99
+ which bounds nothing. Guessing a range from a format we cannot read would
100
+ refuse a write the panel might accept, and the commissioned ceiling still
101
+ applies either way.
102
+
103
+ A property declared `float` is not bounded by its range: only `integer`
104
+ ranges are read.
105
+ """
106
+ if datatype != "integer" or not isinstance(declared, str):
107
+ return None
108
+ parts = declared.split(":")
109
+ if len(parts) != 2:
110
+ return None
111
+ low, high = parts[0].strip(), parts[1].strip()
112
+ if any(text and _PLAIN_INTEGER.fullmatch(text) is None for text in (low, high)):
113
+ return None
114
+ minimum = int(low) if low else None
115
+ maximum = int(high) if high else None
116
+ if minimum is not None and maximum is not None and minimum > maximum:
117
+ return None
118
+ return minimum, maximum
119
+
120
+
84
121
  @dataclass(frozen=True, slots=True)
85
122
  class ChargeLimitProperty:
86
123
  """One declared property of the resolved surface.
@@ -95,6 +132,8 @@ class ChargeLimitProperty:
95
132
  unit: str | None
96
133
  datatype: str
97
134
  settable: bool
135
+ bounds: IntegerBounds | None = None
136
+ """The `<min>:<max>` range the declaration's `$format` states, or None where it states none we can read."""
98
137
 
99
138
 
100
139
  @dataclass(frozen=True, slots=True)
@@ -144,9 +183,11 @@ def resolve_charge_limit(device: DiscoveredDevice | None) -> ChargeLimitSurface
144
183
  def _property(property_id: str, definition: dict[str, object] | None) -> ChargeLimitProperty | None:
145
184
  if definition is None:
146
185
  return None
186
+ datatype = str(definition.get("datatype") or "string")
147
187
  return ChargeLimitProperty(
148
188
  property_id=property_id,
149
189
  unit=optional_str(definition.get("unit")),
150
- datatype=str(definition.get("datatype") or "string"),
190
+ datatype=datatype,
151
191
  settable=declared_settable(definition),
192
+ bounds=declared_integer_bounds(datatype, definition.get("format")),
152
193
  )
@@ -36,6 +36,21 @@ def device_type(device: DiscoveredDevice) -> str:
36
36
  return str(declared) if declared else ""
37
37
 
38
38
 
39
+ def declares_class(declared: str, known: str) -> bool:
40
+ """Whether a declared device type is the class `known` or a subtype of it.
41
+
42
+ eBus device types are hierarchical and a subtype carries its parent's
43
+ class, so a device typed ``…device.bess.hybrid`` is a ``…device.bess``.
44
+ The boundary is the dot: ``…device.lugsfoo`` merely begins with
45
+ ``…device.lugs`` and names a different class.
46
+
47
+ The one answer to the question for every reader of a type -- the snapshot
48
+ roles, adoption, field metadata and discovery -- so that no two of them can
49
+ disagree about which class a device belongs to.
50
+ """
51
+ return declared == known or declared.startswith(f"{known}.")
52
+
53
+
39
54
  def nodes(description: dict[str, object]) -> dict[str, dict[str, object]]:
40
55
  """The capability nodes a description declares, by node id."""
41
56
  declared = description.get("nodes")
@@ -36,12 +36,13 @@ From r202639 the BESS meter is published discharge-positive, the negation of
36
36
  The frame is chosen from the enclosure's release build number
37
37
  (``firmware.release_build``), never from the respin suffix after it. Where the
38
38
  version does not parse, the two wire properties decide instead: one nonzero sign
39
- on both is the old frame, opposite signs the new one. Where either is zero or
39
+ on both is the old frame, opposite signs the new one. The reference capture
40
+ (``ebus-panel-sim`` 0.9.0) publishes no SPAN version string and is read this way:
41
+ it carries r202639's frame, told by its opposite signs. Where either is zero or
40
42
  absent the old frame is assumed, because that is what every earlier release of
41
- this package applied and what the reference capture (``ebus-panel-sim`` 0.8.0,
42
- which publishes no SPAN version string) carries. A battery at rest reads zero in
43
- both frames, so the assumption only decides a nonzero BESS meter beside a missing
44
- or zero flow figure.
43
+ this package applied. A battery at rest reads zero in both frames, so the
44
+ assumption only decides a nonzero BESS meter beside a missing or zero flow
45
+ figure.
45
46
 
46
47
  On either firmware ``panel.power_flow_battery`` (passed through untouched, and
47
48
  charge-positive) and ``battery.power_w`` end up as each other's mirror, so a
@@ -53,7 +54,7 @@ from __future__ import annotations
53
54
  from typing import TYPE_CHECKING
54
55
 
55
56
  from span_panel_api.models import SpanBatterySnapshot, SpanEvseSnapshot, SpanMidSnapshot, SpanPVSnapshot
56
- from span_panel_api_schema_1.charge_limit import ChargeLimitProperty, ChargeLimitSurface, resolve_charge_limit
57
+ from span_panel_api_schema_1.charge_limit import ChargeLimitProperty, ChargeLimitSurface, IntegerBounds, resolve_charge_limit
57
58
  from span_panel_api_schema_1.const import (
58
59
  NODE_CONNECTION,
59
60
  NODE_GRID,
@@ -368,9 +369,12 @@ def build_evse(
368
369
  The charge-current pair is read through `resolve_charge_limit` rather than
369
370
  from named constants, because which node and properties carry it is a
370
371
  question only this charger's `$description` can answer. See
371
- `span_panel_api_schema_1.charge_limit`.
372
+ `span_panel_api_schema_1.charge_limit`. The limit's declared range comes from
373
+ the same resolution, so the range a control offers and the range
374
+ `SchemaOneAdapter.evse_charge_limit_payload` enforces cannot disagree.
372
375
  """
373
376
  limit = resolve_charge_limit(evse)
377
+ minimum, maximum = _limit_bounds(limit)
374
378
  return SpanEvseSnapshot(
375
379
  node_id=node_id,
376
380
  feed_circuit_id=feeds.get(evse.device_id, ""),
@@ -393,9 +397,23 @@ def build_evse(
393
397
  charge_current_ceiling_a=_limit_value(evse, limit, limit.ceiling) if limit else None,
394
398
  charge_current_limit_target_a=_limit_target(evse, limit),
395
399
  charge_current_limit_settable=limit is not None and limit.limit is not None and limit.limit.settable,
400
+ charge_current_limit_minimum_a=minimum,
401
+ charge_current_limit_maximum_a=maximum,
396
402
  )
397
403
 
398
404
 
405
+ def _limit_bounds(surface: ChargeLimitSurface | None) -> IntegerBounds:
406
+ """The range the resolved limit declares, `None` on a side it leaves open or cannot be read.
407
+
408
+ `ChargeLimitProperty.bounds` is already None for a format
409
+ `declared_integer_bounds` will not interpret, so a declaration that bounds
410
+ nothing on the write path reports no range here either.
411
+ """
412
+ if surface is None or surface.limit is None or surface.limit.bounds is None:
413
+ return None, None
414
+ return surface.limit.bounds
415
+
416
+
399
417
  def _limit_value(evse: DiscoveredDevice, surface: ChargeLimitSurface, declaration: ChargeLimitProperty | None) -> int | None:
400
418
  """One half of the resolved charge-limit pair, or None where it is not declared."""
401
419
  if declaration is None: