span-panel-api-schema-1 1.2.2__tar.gz → 1.4.0b1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/CHANGELOG.md +25 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/PKG-INFO +2 -2
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/pyproject.toml +7 -2
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/src/span_panel_api_schema_1/adapter.py +103 -73
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/src/span_panel_api_schema_1/adoption.py +4 -1
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/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.0b1}/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.0b1}/src/span_panel_api_schema_1/devices.py +33 -3
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/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.0b1}/src/span_panel_api_schema_1/field_metadata.py +50 -10
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/src/span_panel_api_schema_1/panel.py +111 -58
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/src/span_panel_api_schema_1/reference/parent_child_tree.json +17 -17
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/src/span_panel_api_schema_1/snapshot.py +30 -20
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/src/span_panel_api_schema_1/transport.py +24 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/.gitignore +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/README.md +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/breaker.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/charge-limit.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/connection.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/door.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/grid-forming.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/grid.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/info.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/load-shed.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/meter.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/pcs.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/power-flows.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/shed-forecast.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/shed.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/soc.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/status.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/switch.json +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/registries/device-types.md +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/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.0b1}/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.0b1}/src/span_panel_api_schema_1/charge_limit.py +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/src/span_panel_api_schema_1/description.py +0 -0
- {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/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.0b1}/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.0b1}/src/span_panel_api_schema_1/spec_lock.json +0 -0
|
@@ -9,6 +9,31 @@ 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
|
+
## [Unreleased]
|
|
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
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- The shipped reference tree is recaptured with every circuit inside the panel's breaker positions.
|
|
23
|
+
- **`proximity_proven` is `None`**, where it was a hard-coded `True`, because the tree publishes no proximity property.
|
|
24
|
+
- **`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
|
+
|
|
26
|
+
### Removed
|
|
27
|
+
|
|
28
|
+
- **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
|
+
- **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
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **A circuit whose shed priority is `UNKNOWN` is no longer reported as sheddable.**
|
|
35
|
+
- **The grid-forming device name falls back to vendor and model instead of showing a device id.**
|
|
36
|
+
|
|
12
37
|
## [1.2.2]
|
|
13
38
|
|
|
14
39
|
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.0b1
|
|
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.0b1
|
|
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.
|
|
3
|
+
version = "1.4.0b1"
|
|
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,12 @@ 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.7.0b1, 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.
|
|
34
|
+
# Raised to 3.8.0b1: this parser fills snapshot fields new in it and imports
|
|
35
|
+
# `device_id_order` from its models.
|
|
36
|
+
"span-panel-api>=3.8.0b1,<4.0",
|
|
32
37
|
# Only this distribution depends on the eBus SDK. The bootstrap and
|
|
33
38
|
# schema-0 stay clean, so a flat-panel install never pulls it in — which is
|
|
34
39
|
# what bounds the release coupling this dependency introduces to panels on
|
|
@@ -48,9 +48,9 @@ from span_panel_api_schema_1.const import (
|
|
|
48
48
|
from span_panel_api_schema_1.description import declared_settable, device_type, node_properties
|
|
49
49
|
from span_panel_api_schema_1.devices import feed_circuit_ids
|
|
50
50
|
from span_panel_api_schema_1.field_metadata import build_field_metadata
|
|
51
|
-
from span_panel_api_schema_1.panel import integer, text
|
|
51
|
+
from span_panel_api_schema_1.panel import integer, positions_outside_model, text
|
|
52
52
|
from span_panel_api_schema_1.snapshot import TreeRoles, build_snapshot, harmonised_evse_keys
|
|
53
|
-
from span_panel_api_schema_1.transport import ControllerRoutes
|
|
53
|
+
from span_panel_api_schema_1.transport import ControllerRoutes, control_target
|
|
54
54
|
|
|
55
55
|
if TYPE_CHECKING:
|
|
56
56
|
from collections.abc import Callable
|
|
@@ -62,11 +62,12 @@ if TYPE_CHECKING:
|
|
|
62
62
|
_LOGGER = logging.getLogger(__name__)
|
|
63
63
|
|
|
64
64
|
FEED_GRACE_S: Final = 2.0
|
|
65
|
-
"""How long an inverter's feed may stay unvalued once
|
|
65
|
+
"""How long an inverter's feed or a DER's model may stay unvalued once every name has arrived.
|
|
66
66
|
|
|
67
|
-
A retained `connection/feeds-device-id` lands in the same burst
|
|
68
|
-
|
|
69
|
-
as absent: a DER may be published with no
|
|
67
|
+
A retained `connection/feeds-device-id` or `info/model` lands in the same burst
|
|
68
|
+
as the rest of the tree's labels. One still missing this long after every name
|
|
69
|
+
and firmware version is taken as absent: a DER may be published with no
|
|
70
|
+
connection record at all, and a declared model may never be valued.
|
|
70
71
|
"""
|
|
71
72
|
|
|
72
73
|
|
|
@@ -110,8 +111,9 @@ class SchemaOneAdapter:
|
|
|
110
111
|
self._serial_number = serial_number
|
|
111
112
|
self._schema = schema
|
|
112
113
|
self._clock = clock
|
|
113
|
-
self.
|
|
114
|
-
self.
|
|
114
|
+
self._grace_since: dict[str, float] = {}
|
|
115
|
+
self._grace_reported: set[str] = set()
|
|
116
|
+
self._positions_reported = False
|
|
115
117
|
self._routes = ControllerRoutes()
|
|
116
118
|
self._controller = Controller(root_device_id=serial_number, mqttc=self._routes)
|
|
117
119
|
self._property_callbacks: list[Callable[[str, str, str, str | None], None]] = []
|
|
@@ -167,10 +169,10 @@ class SchemaOneAdapter:
|
|
|
167
169
|
|
|
168
170
|
The model is required only when the root's description declares it: the
|
|
169
171
|
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`.
|
|
172
|
+
early reports zero spaces, which leaves a consumer laying out the
|
|
173
|
+
panel's positions with none to lay out. Asking only for what the panel
|
|
174
|
+
itself promised keeps a firmware that omits the property connectable —
|
|
175
|
+
it falls back to the drift warning in `panel_size_from_model`.
|
|
174
176
|
"""
|
|
175
177
|
root = self._controller.get_root(self._serial_number)
|
|
176
178
|
if root is None or root.state != STATE_READY or not root.description:
|
|
@@ -186,7 +188,34 @@ class SchemaOneAdapter:
|
|
|
186
188
|
|
|
187
189
|
def build_snapshot(self) -> SpanPanelSnapshot:
|
|
188
190
|
root = self._require_root()
|
|
189
|
-
|
|
191
|
+
snapshot = build_snapshot(root, self._children())
|
|
192
|
+
self._report_positions_outside_model(snapshot)
|
|
193
|
+
return snapshot
|
|
194
|
+
|
|
195
|
+
def _report_positions_outside_model(self, snapshot: SpanPanelSnapshot) -> None:
|
|
196
|
+
"""Warn once per panel when its circuits occupy spaces outside its model's range.
|
|
197
|
+
|
|
198
|
+
The snapshot keeps reporting the model's range, so such a circuit lies
|
|
199
|
+
outside the positions a consumer lays out until the table is corrected.
|
|
200
|
+
"""
|
|
201
|
+
if self._positions_reported:
|
|
202
|
+
return
|
|
203
|
+
outside = positions_outside_model(
|
|
204
|
+
snapshot.model, (tab for circuit in snapshot.circuits.values() for tab in circuit.tabs)
|
|
205
|
+
)
|
|
206
|
+
if outside is None:
|
|
207
|
+
return
|
|
208
|
+
(first, last), (lowest, highest) = outside
|
|
209
|
+
_LOGGER.warning(
|
|
210
|
+
"Panel model %s has breaker positions %d-%d, but its circuits occupy spaces %d-%d; "
|
|
211
|
+
"the snapshot reports the model's positions",
|
|
212
|
+
snapshot.model,
|
|
213
|
+
first,
|
|
214
|
+
last,
|
|
215
|
+
lowest,
|
|
216
|
+
highest,
|
|
217
|
+
)
|
|
218
|
+
self._positions_reported = True
|
|
190
219
|
|
|
191
220
|
def build_field_metadata(self) -> dict[str, FieldMetadata]:
|
|
192
221
|
root = self._controller.get_root(self._serial_number)
|
|
@@ -212,6 +241,12 @@ class SchemaOneAdapter:
|
|
|
212
241
|
the same exposure, so a DER missing the model it declared is reported
|
|
213
242
|
alongside a circuit missing its name.
|
|
214
243
|
|
|
244
|
+
Only a declared label is waited for. A circuit-typed device whose
|
|
245
|
+
description declares no `info/name` -- a meter outside the panel
|
|
246
|
+
declares no `info` node at all -- has no name coming, and waiting for
|
|
247
|
+
one would hold every connect and every tree rebuild to the full
|
|
248
|
+
timeout.
|
|
249
|
+
|
|
215
250
|
An inverter's feeding circuit is waited on too, because it decides the
|
|
216
251
|
inverter's `pv_inverters` key, and a key that moved after the first
|
|
217
252
|
snapshot would move every entity built on it. A circuit feeding nothing
|
|
@@ -223,52 +258,66 @@ class SchemaOneAdapter:
|
|
|
223
258
|
The panel itself is reported while a declared BESS waits on the
|
|
224
259
|
panel's declared firmware version, which decides the BESS meter's frame.
|
|
225
260
|
|
|
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
|
-
|
|
261
|
+
The feed and model waits are bounded by `FEED_GRACE_S`. A DER may be
|
|
262
|
+
published with no connection record pointing at it, and a declared
|
|
263
|
+
model may never be valued; then no value is coming. Both land in the
|
|
264
|
+
same burst as the circuits' names, so once every name and firmware
|
|
265
|
+
version has arrived, a feed or model still unvalued after the grace is
|
|
266
|
+
taken as absent and reported once per device at INFO, rather than the
|
|
267
|
+
transport warning after the full name timeout. An inverter whose feed
|
|
268
|
+
was taken as absent is keyed by its device id; a model fills device
|
|
269
|
+
metadata only, so one that lands later moves nothing. Each device's
|
|
270
|
+
grace starts when it is first waited on alone and restarts whenever a
|
|
271
|
+
name or firmware version is missing again, so a device that appears
|
|
272
|
+
after another's grace has run gets a whole grace of its own.
|
|
236
273
|
"""
|
|
237
274
|
roles = TreeRoles(self._children())
|
|
238
|
-
missing = [
|
|
275
|
+
missing = [
|
|
276
|
+
circuit.device_id
|
|
277
|
+
for circuit in roles.circuits
|
|
278
|
+
if PROP_NAME in circuit.get_node_properties(NODE_INFO) and not circuit.get_property(NODE_INFO, PROP_NAME)
|
|
279
|
+
]
|
|
280
|
+
missing.extend(self._firmware_version_pending(roles))
|
|
239
281
|
ders = (roles.bess, *roles.pvs, *roles.evse)
|
|
240
|
-
|
|
241
|
-
device.device_id
|
|
282
|
+
graced = {
|
|
283
|
+
device.device_id: f"{NODE_INFO}/{PROP_MODEL}"
|
|
242
284
|
for device in ders
|
|
243
285
|
if device is not None
|
|
244
286
|
and PROP_MODEL in device.get_node_properties(NODE_INFO)
|
|
245
287
|
and device.get_property(NODE_INFO, PROP_MODEL) is None
|
|
288
|
+
}
|
|
289
|
+
graced.update(
|
|
290
|
+
(circuit_id, f"{NODE_CONNECTION}/{PROP_FEEDS_DEVICE_ID}")
|
|
291
|
+
for circuit_id in _unvalued_feeds_for_unplaced_pvs(roles)
|
|
246
292
|
)
|
|
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)
|
|
293
|
+
missing.extend(self._within_grace(graced, held=bool(missing)))
|
|
270
294
|
return missing
|
|
271
295
|
|
|
296
|
+
def _within_grace(self, pending: dict[str, str], *, held: bool) -> list[str]:
|
|
297
|
+
"""The devices of `pending` whose grace has not run out.
|
|
298
|
+
|
|
299
|
+
`pending` maps each device to the declared `node/property` it has not
|
|
300
|
+
valued. While `held`, something without a grace is still missing, so
|
|
301
|
+
every device is waited on and no grace runs. A device whose grace runs
|
|
302
|
+
out is reported once, and stops being waited on until its value arrives
|
|
303
|
+
or a hold starts its grace again.
|
|
304
|
+
"""
|
|
305
|
+
for device_id in self._grace_since.keys() - pending.keys():
|
|
306
|
+
del self._grace_since[device_id]
|
|
307
|
+
self._grace_reported &= pending.keys()
|
|
308
|
+
if held:
|
|
309
|
+
self._grace_since.clear()
|
|
310
|
+
return list(pending)
|
|
311
|
+
now = self._clock()
|
|
312
|
+
waiting: list[str] = []
|
|
313
|
+
for device_id, path in pending.items():
|
|
314
|
+
if now - self._grace_since.setdefault(device_id, now) < FEED_GRACE_S:
|
|
315
|
+
waiting.append(device_id)
|
|
316
|
+
elif device_id not in self._grace_reported:
|
|
317
|
+
_LOGGER.info("%s declares %s and has not published it; taking it as absent", device_id, path)
|
|
318
|
+
self._grace_reported.add(device_id)
|
|
319
|
+
return waiting
|
|
320
|
+
|
|
272
321
|
def _firmware_version_pending(self, roles: TreeRoles) -> list[str]:
|
|
273
322
|
"""The panel itself, while a declared BESS waits on the panel's firmware version.
|
|
274
323
|
|
|
@@ -308,7 +357,7 @@ class SchemaOneAdapter:
|
|
|
308
357
|
"""Where this circuit's relay is commanded, or None if it may not be.
|
|
309
358
|
|
|
310
359
|
**None where the panel declares the relay non-commandable**, by either of
|
|
311
|
-
the two signals `relay_is_settable` reads. `
|
|
360
|
+
the two signals `relay_is_settable` reads. `control_target` is pure string
|
|
312
361
|
formatting from a device id, so building the topic unconditionally aimed
|
|
313
362
|
a write at a circuit the panel commissioned as always-on — every part of
|
|
314
363
|
the refusal was already in the tree, and nothing consulted it.
|
|
@@ -325,7 +374,7 @@ class SchemaOneAdapter:
|
|
|
325
374
|
device = self._child(circuit_id)
|
|
326
375
|
if device is None or not relay_is_settable(device):
|
|
327
376
|
return None
|
|
328
|
-
return
|
|
377
|
+
return control_target(circuit_id, NODE_SWITCH, PROP_RELAY)
|
|
329
378
|
|
|
330
379
|
def set_circuit_priority_target(self, circuit_id: str) -> ControlTarget | None:
|
|
331
380
|
"""Where this circuit's shed priority is written, or None if it may not be.
|
|
@@ -342,7 +391,7 @@ class SchemaOneAdapter:
|
|
|
342
391
|
device = self._child(circuit_id)
|
|
343
392
|
if device is None or not priority_is_settable(device):
|
|
344
393
|
return None
|
|
345
|
-
return
|
|
394
|
+
return control_target(circuit_id, NODE_LOAD_SHED, PROP_PRIORITY)
|
|
346
395
|
|
|
347
396
|
def has_circuit(self, circuit_id: str) -> bool:
|
|
348
397
|
"""Whether the tree carries a circuit under this id.
|
|
@@ -398,7 +447,7 @@ class SchemaOneAdapter:
|
|
|
398
447
|
root = self._controller.get_root(self._serial_number)
|
|
399
448
|
if not declared_settable(node_properties(root, NODE_SHED).get(PROP_ASSERTED_ISLANDING_STATE)):
|
|
400
449
|
return None
|
|
401
|
-
return
|
|
450
|
+
return control_target(self._serial_number, NODE_SHED, PROP_ASSERTED_ISLANDING_STATE)
|
|
402
451
|
|
|
403
452
|
def dominant_power_source_payload(self, value: str) -> str | None:
|
|
404
453
|
"""Translate a flat `dominant-power-source` value into an assertion.
|
|
@@ -447,7 +496,7 @@ class SchemaOneAdapter:
|
|
|
447
496
|
if writable is None:
|
|
448
497
|
return None
|
|
449
498
|
device, surface, limit = writable
|
|
450
|
-
return
|
|
499
|
+
return control_target(device.device_id, surface.node, limit.property_id)
|
|
451
500
|
|
|
452
501
|
def evse_charge_limit_payload(self, node_id: str, amps: int) -> str | None:
|
|
453
502
|
"""The payload to publish for `amps`, or None if it may not be published.
|
|
@@ -514,25 +563,6 @@ class SchemaOneAdapter:
|
|
|
514
563
|
return device, surface, surface.limit
|
|
515
564
|
return None
|
|
516
565
|
|
|
517
|
-
def _target(self, device_id: str, node: str, prop: str) -> ControlTarget:
|
|
518
|
-
"""One device/node/property address as both a set topic and an observation key.
|
|
519
|
-
|
|
520
|
-
The triple is returned alongside the topic rather than left for the
|
|
521
|
-
transport to parse back out of it: the transport is the one component
|
|
522
|
-
that is supposed to know nothing about this schema's topic grammar, and
|
|
523
|
-
under parent/child the device is a peer of the panel rather than a node
|
|
524
|
-
beneath it, so the grammar is not even the flat one.
|
|
525
|
-
|
|
526
|
-
The spelling here is the spelling `_on_property_changed` reports under,
|
|
527
|
-
because a write is verified by matching one against the other.
|
|
528
|
-
"""
|
|
529
|
-
return ControlTarget(
|
|
530
|
-
topic=f"{HOMIE_DOMAIN}/{HOMIE_VERSION}/{device_id}/{node}/{prop}/set",
|
|
531
|
-
device_id=device_id,
|
|
532
|
-
node_id=node,
|
|
533
|
-
property_id=prop,
|
|
534
|
-
)
|
|
535
|
-
|
|
536
566
|
def _require_root(self) -> DiscoveredDevice:
|
|
537
567
|
"""The root, or a clear error if discovery has not finished.
|
|
538
568
|
|
|
@@ -25,6 +25,7 @@ from span_panel_api.models import ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE
|
|
|
25
25
|
from span_panel_api_schema_1.const import (
|
|
26
26
|
HOMIE_DOMAIN,
|
|
27
27
|
HOMIE_VERSION,
|
|
28
|
+
SITE_PROPERTIES,
|
|
28
29
|
TYPE_BESS,
|
|
29
30
|
TYPE_CIRCUIT,
|
|
30
31
|
TYPE_EVSE,
|
|
@@ -132,7 +133,7 @@ def _adopt(device: DiscoveredDevice, declared: str) -> AdoptedDevice:
|
|
|
132
133
|
|
|
133
134
|
|
|
134
135
|
def _readings(device: DiscoveredDevice, declared_nodes: dict[str, dict[str, object]]) -> tuple[AdoptedProperty, ...]:
|
|
135
|
-
"""Every declared property outside the identity and topology nodes.
|
|
136
|
+
"""Every declared property outside the identity and topology nodes, and no site property.
|
|
136
137
|
|
|
137
138
|
Those two are excluded by *node*, which is what the eBus vocabulary defines,
|
|
138
139
|
rather than by property name. The catalogs carry no marker for "this string
|
|
@@ -146,6 +147,8 @@ def _readings(device: DiscoveredDevice, declared_nodes: dict[str, dict[str, obje
|
|
|
146
147
|
if node_id in (ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE):
|
|
147
148
|
continue
|
|
148
149
|
for property_id, definition in properties(node).items():
|
|
150
|
+
if property_id in SITE_PROPERTIES:
|
|
151
|
+
continue
|
|
149
152
|
raw = device.get_property(node_id, property_id)
|
|
150
153
|
# The same reading the curated controls get: this one gates a set
|
|
151
154
|
# topic on an uncurated device, so a declaration that has not said
|
|
@@ -8,52 +8,84 @@ derivations are defined by the migration guide, not invented here:
|
|
|
8
8
|
====================== ===========================================================
|
|
9
9
|
Flat property v1.0 source
|
|
10
10
|
====================== ===========================================================
|
|
11
|
-
``always-on`` ``switch/relay-controllable``, inverted
|
|
11
|
+
``always-on`` ``switch/relay-controllable``, inverted, where a relay is declared
|
|
12
12
|
``never-backup`` ``$settable`` on ``load-shed/priority``, inverted
|
|
13
|
-
``sheddable`` computed:
|
|
13
|
+
``sheddable`` computed: a shed priority and ``relay-controllable``
|
|
14
14
|
====================== ===========================================================
|
|
15
15
|
|
|
16
|
+
A shed priority is ``OFF_GRID`` or ``SOC_THRESHOLD``. SPAN's r202633 changelog
|
|
17
|
+
derives sheddable as ``priority != NEVER`` and asks consumers to treat ``UNKNOWN``
|
|
18
|
+
as "not yet known" rather than as sheddable, so the rule names the priorities the
|
|
19
|
+
panel sheds by instead of excluding the one it never sheds.
|
|
20
|
+
|
|
16
21
|
Sign and direction are unchanged from the flat schema, and both are the reverse
|
|
17
22
|
of what the property names suggest. Values are in the enclosure's reference
|
|
18
23
|
frame: a normal load reads **negative** ``active-power`` and accumulates
|
|
19
24
|
``exported-energy`` (the panel exported it *to* the circuit). The snapshot
|
|
20
25
|
reports consumption as positive, so power is negated and the two energy
|
|
21
26
|
accumulators are swapped.
|
|
27
|
+
|
|
28
|
+
**A circuit-typed device that declares no breaker space is a meter outside the
|
|
29
|
+
panel**, and none of that applies to it. Its ``active-power`` is already
|
|
30
|
+
import-positive (positive = flowing into the panel), so it is reported as
|
|
31
|
+
published, with ``imported-energy`` as consumed and ``exported-energy`` as
|
|
32
|
+
produced: consumed minus produced is net import. The classification reads the
|
|
33
|
+
declaration, never a value, so a meter that has not reported is already outside
|
|
34
|
+
the panel and a hosted circuit that has not reported is still hosted.
|
|
22
35
|
"""
|
|
23
36
|
|
|
24
37
|
from __future__ import annotations
|
|
25
38
|
|
|
26
|
-
from typing import TYPE_CHECKING
|
|
39
|
+
from typing import TYPE_CHECKING, Final
|
|
27
40
|
|
|
28
|
-
from span_panel_api.models import
|
|
41
|
+
from span_panel_api.models import (
|
|
42
|
+
FEEDS_ROLES,
|
|
43
|
+
PROTECTION_FUNCTIONS,
|
|
44
|
+
FeedsRole,
|
|
45
|
+
ProtectionFunction,
|
|
46
|
+
SpanCircuitSnapshot,
|
|
47
|
+
device_id_order,
|
|
48
|
+
)
|
|
29
49
|
from span_panel_api_schema_1.const import (
|
|
30
50
|
NODE_BREAKER,
|
|
51
|
+
NODE_CONNECTION,
|
|
31
52
|
NODE_INFO,
|
|
32
53
|
NODE_LOAD_SHED,
|
|
33
54
|
NODE_METER,
|
|
34
55
|
NODE_PCS,
|
|
35
56
|
NODE_SWITCH,
|
|
36
|
-
|
|
57
|
+
PRIORITY_OFF_GRID,
|
|
58
|
+
PRIORITY_SOC_THRESHOLD,
|
|
37
59
|
PROP_ACTIVE_POWER,
|
|
38
60
|
PROP_CURRENT,
|
|
39
61
|
PROP_EXPORTED_ENERGY,
|
|
62
|
+
PROP_FEEDS_ROLE,
|
|
40
63
|
PROP_IMPORTED_ENERGY,
|
|
41
64
|
PROP_MANAGED,
|
|
42
65
|
PROP_NAME,
|
|
66
|
+
PROP_NOMINAL_VOLTAGE,
|
|
43
67
|
PROP_POLES,
|
|
44
68
|
PROP_PRIORITY,
|
|
69
|
+
PROP_PROTECTION_FUNCTIONS,
|
|
45
70
|
PROP_RATING,
|
|
46
71
|
PROP_RELAY,
|
|
47
72
|
PROP_RELAY_CONTROLLABLE,
|
|
48
73
|
PROP_RELAY_REQUESTER,
|
|
74
|
+
PROP_SHARED_WITH_DEVICE_IDS,
|
|
49
75
|
PROP_SPACES,
|
|
50
76
|
UNKNOWN,
|
|
51
77
|
)
|
|
52
78
|
from span_panel_api_schema_1.description import declared_settable, node_properties
|
|
79
|
+
from span_panel_api_schema_1.panel import declares_node
|
|
53
80
|
|
|
54
81
|
if TYPE_CHECKING:
|
|
82
|
+
from collections.abc import Collection
|
|
83
|
+
|
|
55
84
|
from ebus_sdk.homie import DiscoveredDevice
|
|
56
85
|
|
|
86
|
+
_SHED_PRIORITIES: Final = frozenset({PRIORITY_OFF_GRID, PRIORITY_SOC_THRESHOLD})
|
|
87
|
+
"""The `load-shed/priority` values the panel sheds a circuit by; see the module docstring."""
|
|
88
|
+
|
|
57
89
|
|
|
58
90
|
def _text(device: DiscoveredDevice, node: str, prop: str, default: str = "") -> str:
|
|
59
91
|
value = device.get_property(node, prop)
|
|
@@ -79,10 +111,11 @@ def _number(device: DiscoveredDevice, node: str, prop: str) -> float | None:
|
|
|
79
111
|
def _flag(device: DiscoveredDevice, node: str, prop: str, *, default: bool) -> bool:
|
|
80
112
|
"""Read a Homie boolean. Absent means `default`, which is not always False.
|
|
81
113
|
|
|
82
|
-
`relay-controllable` absent has to mean *controllable
|
|
83
|
-
property exists to mark the exception (an
|
|
84
|
-
to False would silently make every circuit
|
|
85
|
-
omits it.
|
|
114
|
+
`relay-controllable` absent has to mean *controllable* on a device that
|
|
115
|
+
declares a relay, because the property exists to mark the exception (an
|
|
116
|
+
always-on circuit). Defaulting it to False would silently make every circuit
|
|
117
|
+
uncontrollable on a panel that omits it. See `_relay_controllable` for a
|
|
118
|
+
device that declares no relay.
|
|
86
119
|
"""
|
|
87
120
|
raw = device.get_property(node, prop)
|
|
88
121
|
if raw is None or raw == "":
|
|
@@ -116,6 +149,28 @@ def _optional_integer(device: DiscoveredDevice, node: str, prop: str) -> int | N
|
|
|
116
149
|
return None if raw is None else int(raw)
|
|
117
150
|
|
|
118
151
|
|
|
152
|
+
def declares_spaces(device: DiscoveredDevice) -> bool:
|
|
153
|
+
"""Whether the device's `$description` declares ``info/spaces``.
|
|
154
|
+
|
|
155
|
+
The hosted-or-outside question, asked of the declaration because a value
|
|
156
|
+
cannot answer it: a hosted circuit whose spaces have not arrived yet and a
|
|
157
|
+
meter that has none read the same until the values land, and a consumer
|
|
158
|
+
that keyed the sign of a reading on that gap would book the first readings
|
|
159
|
+
of every hosted circuit backwards.
|
|
160
|
+
"""
|
|
161
|
+
return PROP_SPACES in node_properties(device, NODE_INFO)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _relay_controllable(device: DiscoveredDevice) -> bool:
|
|
165
|
+
"""``switch/relay-controllable``, defaulting to whether a relay is declared at all.
|
|
166
|
+
|
|
167
|
+
A device that declares no ``switch`` node has no relay to command, so it is
|
|
168
|
+
not controllable whatever it leaves unpublished; one that declares the node
|
|
169
|
+
and leaves the property unvalued is, for the reason `_flag` gives.
|
|
170
|
+
"""
|
|
171
|
+
return _flag(device, NODE_SWITCH, PROP_RELAY_CONTROLLABLE, default=declares_node(device, NODE_SWITCH))
|
|
172
|
+
|
|
173
|
+
|
|
119
174
|
def _tabs(device: DiscoveredDevice) -> list[int]:
|
|
120
175
|
"""Breaker spaces from ``info/spaces``.
|
|
121
176
|
|
|
@@ -226,21 +281,68 @@ def relay_is_settable(device: DiscoveredDevice) -> bool:
|
|
|
226
281
|
specification's rule showing up in hardware. The vendored capture carries
|
|
227
282
|
the same pattern on its five.
|
|
228
283
|
"""
|
|
229
|
-
return _settable(device, NODE_SWITCH, PROP_RELAY) and
|
|
284
|
+
return _settable(device, NODE_SWITCH, PROP_RELAY) and _relay_controllable(device)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _feeds_role(device: DiscoveredDevice) -> FeedsRole | None:
|
|
288
|
+
"""The declared `connection/feeds-role`, or None when unpublished or outside the role set."""
|
|
289
|
+
published = _text(device, NODE_CONNECTION, PROP_FEEDS_ROLE)
|
|
290
|
+
return next((role for role in FEEDS_ROLES if role == published), None)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _protection_functions(device: DiscoveredDevice) -> tuple[ProtectionFunction, ...] | None:
|
|
294
|
+
"""The published `breaker/protection-functions`, in order, or None when unpublished.
|
|
295
|
+
|
|
296
|
+
A comma-separated list of the enum's members; a value outside
|
|
297
|
+
`PROTECTION_FUNCTIONS` is dropped rather than passed on.
|
|
298
|
+
"""
|
|
299
|
+
published = device.get_property(NODE_BREAKER, PROP_PROTECTION_FUNCTIONS)
|
|
300
|
+
if published is None:
|
|
301
|
+
return None
|
|
302
|
+
listed = [part.strip() for part in str(published).split(",")]
|
|
303
|
+
return tuple(function for part in listed for function in PROTECTION_FUNCTIONS if function == part)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _shared_with(device: DiscoveredDevice, node: str, circuit_ids: Collection[str]) -> tuple[str, ...] | None:
|
|
307
|
+
"""One node's `shared-with-device-ids`, resolved to the other circuits of the snapshot.
|
|
308
|
+
|
|
309
|
+
A circuit's snapshot key is its device id, so resolving keeps the listed ids
|
|
310
|
+
that name another circuit, in `device_id_order`. `None` while the list is
|
|
311
|
+
undeclared or unpublished; an empty tuple where it names no known circuit.
|
|
312
|
+
"""
|
|
313
|
+
published = device.get_property(node, PROP_SHARED_WITH_DEVICE_IDS)
|
|
314
|
+
if published is None:
|
|
315
|
+
return None
|
|
316
|
+
listed = {part.strip() for part in str(published).split(",")}
|
|
317
|
+
return tuple(sorted((listed & set(circuit_ids)) - {device.device_id}, key=device_id_order))
|
|
230
318
|
|
|
231
319
|
|
|
232
320
|
def build_circuit(
|
|
233
|
-
device: DiscoveredDevice,
|
|
321
|
+
device: DiscoveredDevice,
|
|
322
|
+
device_type: str = "circuit",
|
|
323
|
+
relative_position: str = "",
|
|
324
|
+
circuit_ids: Collection[str] = frozenset(),
|
|
234
325
|
) -> SpanCircuitSnapshot:
|
|
235
|
-
"""Build one circuit snapshot from its v1.0 device.
|
|
236
|
-
raw_power = _number(device, NODE_METER, PROP_ACTIVE_POWER)
|
|
237
|
-
# Negate so positive means consumption. The guard keeps -0.0 out of the
|
|
238
|
-
# snapshot, where it would compare equal to 0.0 but format as "-0.0".
|
|
239
|
-
# A meter that has not reported stays `None` rather than becoming 0.0 W —
|
|
240
|
-
# see `SpanCircuitSnapshot` for why absent and zero must not collapse.
|
|
241
|
-
instant_power_w = None if raw_power is None else (0.0 if raw_power == 0.0 else -raw_power)
|
|
326
|
+
"""Build one circuit snapshot from its v1.0 device.
|
|
242
327
|
|
|
243
|
-
|
|
328
|
+
`circuit_ids` is every circuit device id in the tree, against which the
|
|
329
|
+
circuit's shared-with lists are resolved.
|
|
330
|
+
"""
|
|
331
|
+
outside = not declares_spaces(device)
|
|
332
|
+
raw_power = _number(device, NODE_METER, PROP_ACTIVE_POWER)
|
|
333
|
+
# A hosted circuit is negated so positive means consumption; a meter outside
|
|
334
|
+
# the panel is already import-positive and is reported as published. The
|
|
335
|
+
# guard keeps -0.0 out of the snapshot, where it would compare equal to 0.0
|
|
336
|
+
# but format as "-0.0". A meter that has not reported stays `None` rather
|
|
337
|
+
# than becoming 0.0 W — see `SpanCircuitSnapshot` for why absent and zero
|
|
338
|
+
# must not collapse.
|
|
339
|
+
frame = 1.0 if outside else -1.0
|
|
340
|
+
instant_power_w = None if raw_power is None else (0.0 if raw_power == 0.0 else frame * raw_power)
|
|
341
|
+
imported_wh = _number(device, NODE_METER, PROP_IMPORTED_ENERGY)
|
|
342
|
+
exported_wh = _number(device, NODE_METER, PROP_EXPORTED_ENERGY)
|
|
343
|
+
poles = _number(device, NODE_BREAKER, PROP_POLES)
|
|
344
|
+
|
|
345
|
+
relay_controllable = _relay_controllable(device)
|
|
244
346
|
priority = _text(device, NODE_LOAD_SHED, PROP_PRIORITY, UNKNOWN)
|
|
245
347
|
priority_settable = priority_is_settable(device)
|
|
246
348
|
|
|
@@ -249,25 +351,34 @@ def build_circuit(
|
|
|
249
351
|
name=_text(device, NODE_INFO, PROP_NAME),
|
|
250
352
|
relay_state=_text(device, NODE_SWITCH, PROP_RELAY, UNKNOWN),
|
|
251
353
|
instant_power_w=instant_power_w,
|
|
252
|
-
#
|
|
253
|
-
# produced it
|
|
254
|
-
# circuit's.
|
|
255
|
-
|
|
256
|
-
|
|
354
|
+
# On a hosted circuit the panel *imported* this energy from the
|
|
355
|
+
# circuit, so the circuit produced it: named from the panel's
|
|
356
|
+
# perspective, reported from the circuit's. A meter outside the panel
|
|
357
|
+
# measures the panel's own import, so its imported energy is consumed.
|
|
358
|
+
produced_energy_wh=exported_wh if outside else imported_wh,
|
|
359
|
+
consumed_energy_wh=imported_wh if outside else exported_wh,
|
|
257
360
|
tabs=_tabs(device),
|
|
258
361
|
priority=priority,
|
|
259
362
|
# `always-on` is `not relay-controllable`, and the flat schema derived
|
|
260
363
|
# user-controllability from `always-on` — so this is the same answer by
|
|
261
364
|
# a shorter route.
|
|
262
365
|
is_user_controllable=relay_controllable,
|
|
263
|
-
is_sheddable=priority
|
|
366
|
+
is_sheddable=priority in _SHED_PRIORITIES and relay_controllable,
|
|
264
367
|
is_never_backup=not priority_settable,
|
|
265
368
|
device_type=device_type,
|
|
266
369
|
relative_position=relative_position,
|
|
267
|
-
|
|
370
|
+
nominal_voltage_v=_number(device, NODE_INFO, PROP_NOMINAL_VOLTAGE),
|
|
371
|
+
protection_functions=_protection_functions(device),
|
|
372
|
+
feeds_role=_feeds_role(device),
|
|
373
|
+
meter_shared_with=_shared_with(device, NODE_METER, circuit_ids),
|
|
374
|
+
relay_shared_with=_shared_with(device, NODE_SWITCH, circuit_ids),
|
|
375
|
+
# Unknown without a pole count: a meter outside the panel has no
|
|
376
|
+
# breaker, and a breaker whose count has not arrived has not said.
|
|
377
|
+
is_240v=None if poles is None else poles >= 2,
|
|
268
378
|
current_a=_number(device, NODE_METER, PROP_CURRENT),
|
|
269
379
|
breaker_rating_a=_number(device, NODE_BREAKER, PROP_RATING),
|
|
270
|
-
|
|
380
|
+
# A relay locked closed. A device with no relay is not "on" at all.
|
|
381
|
+
always_on=declares_node(device, NODE_SWITCH) and not relay_controllable,
|
|
271
382
|
relay_requester=_text(device, NODE_SWITCH, PROP_RELAY_REQUESTER, UNKNOWN),
|
|
272
383
|
relay_state_target=device.get_property_target(NODE_SWITCH, PROP_RELAY),
|
|
273
384
|
priority_target=device.get_property_target(NODE_LOAD_SHED, PROP_PRIORITY),
|
|
@@ -282,4 +393,5 @@ def build_circuit(
|
|
|
282
393
|
# policy, both, or neither, so they are read separately and named apart.
|
|
283
394
|
pcs_managed=_optional_flag(device, NODE_PCS, PROP_MANAGED),
|
|
284
395
|
pcs_priority=_optional_integer(device, NODE_PCS, PROP_PRIORITY),
|
|
396
|
+
measures_outside_panel=outside,
|
|
285
397
|
)
|