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.
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/.gitignore +1 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/CHANGELOG.md +8 -2
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/PKG-INFO +2 -2
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/pyproject.toml +4 -2
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- span_panel_api_schema_1-1.4.0b4/src/span_panel_api_schema_1/tree_export.py +924 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/README.md +0 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/breaker.json +0 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/charge-limit.json +0 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/connection.json +0 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/door.json +0 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/grid-forming.json +0 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/grid.json +0 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/info.json +0 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/load-shed.json +0 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/meter.json +0 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/pcs.json +0 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/power-flows.json +0 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/shed-forecast.json +0 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/shed.json +0 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/soc.json +0 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/status.json +0 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/catalogs/switch.json +0 -0
- {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b4}/spec/registries/device-types.md +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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,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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
|
517
|
-
the
|
|
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,
|
|
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
|
|
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
|
|
76
|
-
|
|
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
|
|
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=
|
|
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.
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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:
|