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.
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/.gitignore +1 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/CHANGELOG.md +32 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/PKG-INFO +4 -2
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/README.md +2 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/pyproject.toml +8 -2
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- span_panel_api_schema_1-1.4.0/src/span_panel_api_schema_1/tree_export.py +924 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/breaker.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/charge-limit.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/connection.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/door.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/grid-forming.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/grid.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/info.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/load-shed.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/meter.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/pcs.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/power-flows.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/shed-forecast.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/shed.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/soc.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/status.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/catalogs/switch.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0}/spec/registries/device-types.md +0 -0
- {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
- {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
- {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
- {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
- {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
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
68
|
-
|
|
69
|
-
as absent: a DER may be published with no
|
|
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.
|
|
114
|
-
self.
|
|
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
|
|
171
|
-
|
|
172
|
-
promised keeps a firmware that omits the property connectable —
|
|
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
|
-
|
|
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
|
|
227
|
-
no connection record pointing at it, and
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
warning after the full name timeout.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 = [
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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. `
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
468
|
-
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.
|
|
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,
|
|
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
|
|
518
|
-
"""
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|