span-panel-api-schema-1 1.3.0b1__tar.gz → 1.4.0b2__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.3.0b1 → span_panel_api_schema_1-1.4.0b2}/CHANGELOG.md +8 -1
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/PKG-INFO +2 -2
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/pyproject.toml +4 -2
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/src/span_panel_api_schema_1/adapter.py +99 -69
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/src/span_panel_api_schema_1/adoption.py +4 -1
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/src/span_panel_api_schema_1/circuits.py +126 -23
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/src/span_panel_api_schema_1/const.py +40 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/src/span_panel_api_schema_1/devices.py +33 -3
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/src/span_panel_api_schema_1/extension.py +10 -2
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/src/span_panel_api_schema_1/field_metadata.py +50 -10
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/src/span_panel_api_schema_1/panel.py +105 -35
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/src/span_panel_api_schema_1/reference/parent_child_tree.json +17 -17
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/src/span_panel_api_schema_1/snapshot.py +22 -4
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/src/span_panel_api_schema_1/transport.py +24 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/.gitignore +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/README.md +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/breaker.json +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/charge-limit.json +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/connection.json +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/door.json +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/grid-forming.json +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/grid.json +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/info.json +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/load-shed.json +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/meter.json +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/pcs.json +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/power-flows.json +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/shed-forecast.json +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/shed.json +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/soc.json +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/status.json +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/switch.json +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/registries/device-types.md +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/src/span_panel_api_schema_1/__init__.py +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/src/span_panel_api_schema_1/catalog.py +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/src/span_panel_api_schema_1/charge_limit.py +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/src/span_panel_api_schema_1/description.py +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/src/span_panel_api_schema_1/firmware.py +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/src/span_panel_api_schema_1/py.typed +0 -0
- {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/src/span_panel_api_schema_1/spec_lock.json +0 -0
|
@@ -11,10 +11,15 @@ Pre-releases are not listed separately. A beta is a step towards the next public
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
-
Requires `span-panel-api` **3.
|
|
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.**
|
|
15
19
|
|
|
16
20
|
### Changed
|
|
17
21
|
|
|
22
|
+
- The shipped reference tree is recaptured with every circuit inside the panel's breaker positions.
|
|
18
23
|
- **`proximity_proven` is `None`**, where it was a hard-coded `True`, because the tree publishes no proximity property.
|
|
19
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.
|
|
20
25
|
|
|
@@ -28,6 +33,8 @@ Requires `span-panel-api` **3.7.0 or newer**, whose snapshot fields accept the u
|
|
|
28
33
|
|
|
29
34
|
- **A circuit whose shed priority is `UNKNOWN` is no longer reported as sheddable.**
|
|
30
35
|
- **The grid-forming device name falls back to vendor and model instead of showing a device id.**
|
|
36
|
+
- **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
|
+
([#215](https://github.com/SpanPanel/span-panel-api/issues/215)).
|
|
31
38
|
|
|
32
39
|
## [1.2.2]
|
|
33
40
|
|
|
@@ -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.0b2
|
|
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.0b2"
|
|
4
4
|
description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "SpanPanel"}
|
|
@@ -31,7 +31,9 @@ dependencies = [
|
|
|
31
31
|
# Raised to 3.7.0b1, whose snapshot fields accept the unknown values this
|
|
32
32
|
# parser now reports: against an earlier one, the update times it leaves
|
|
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
|
+
# `device_id_order` from its models.
|
|
36
|
+
"span-panel-api>=3.8.0b1,<4.0",
|
|
35
37
|
# Only this distribution depends on the eBus SDK. The bootstrap and
|
|
36
38
|
# schema-0 stay clean, so a flat-panel install never pulls it in — which is
|
|
37
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]] = []
|
|
@@ -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,7 +8,7 @@ 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
13
|
``sheddable`` computed: a shed priority and ``relay-controllable``
|
|
14
14
|
====================== ===========================================================
|
|
@@ -24,15 +24,31 @@ frame: a normal load reads **negative** ``active-power`` and accumulates
|
|
|
24
24
|
``exported-energy`` (the panel exported it *to* the circuit). The snapshot
|
|
25
25
|
reports consumption as positive, so power is negated and the two energy
|
|
26
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.
|
|
27
35
|
"""
|
|
28
36
|
|
|
29
37
|
from __future__ import annotations
|
|
30
38
|
|
|
31
39
|
from typing import TYPE_CHECKING, Final
|
|
32
40
|
|
|
33
|
-
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
|
+
)
|
|
34
49
|
from span_panel_api_schema_1.const import (
|
|
35
50
|
NODE_BREAKER,
|
|
51
|
+
NODE_CONNECTION,
|
|
36
52
|
NODE_INFO,
|
|
37
53
|
NODE_LOAD_SHED,
|
|
38
54
|
NODE_METER,
|
|
@@ -43,21 +59,28 @@ from span_panel_api_schema_1.const import (
|
|
|
43
59
|
PROP_ACTIVE_POWER,
|
|
44
60
|
PROP_CURRENT,
|
|
45
61
|
PROP_EXPORTED_ENERGY,
|
|
62
|
+
PROP_FEEDS_ROLE,
|
|
46
63
|
PROP_IMPORTED_ENERGY,
|
|
47
64
|
PROP_MANAGED,
|
|
48
65
|
PROP_NAME,
|
|
66
|
+
PROP_NOMINAL_VOLTAGE,
|
|
49
67
|
PROP_POLES,
|
|
50
68
|
PROP_PRIORITY,
|
|
69
|
+
PROP_PROTECTION_FUNCTIONS,
|
|
51
70
|
PROP_RATING,
|
|
52
71
|
PROP_RELAY,
|
|
53
72
|
PROP_RELAY_CONTROLLABLE,
|
|
54
73
|
PROP_RELAY_REQUESTER,
|
|
74
|
+
PROP_SHARED_WITH_DEVICE_IDS,
|
|
55
75
|
PROP_SPACES,
|
|
56
76
|
UNKNOWN,
|
|
57
77
|
)
|
|
58
78
|
from span_panel_api_schema_1.description import declared_settable, node_properties
|
|
79
|
+
from span_panel_api_schema_1.panel import declares_node
|
|
59
80
|
|
|
60
81
|
if TYPE_CHECKING:
|
|
82
|
+
from collections.abc import Collection
|
|
83
|
+
|
|
61
84
|
from ebus_sdk.homie import DiscoveredDevice
|
|
62
85
|
|
|
63
86
|
_SHED_PRIORITIES: Final = frozenset({PRIORITY_OFF_GRID, PRIORITY_SOC_THRESHOLD})
|
|
@@ -88,10 +111,11 @@ def _number(device: DiscoveredDevice, node: str, prop: str) -> float | None:
|
|
|
88
111
|
def _flag(device: DiscoveredDevice, node: str, prop: str, *, default: bool) -> bool:
|
|
89
112
|
"""Read a Homie boolean. Absent means `default`, which is not always False.
|
|
90
113
|
|
|
91
|
-
`relay-controllable` absent has to mean *controllable
|
|
92
|
-
property exists to mark the exception (an
|
|
93
|
-
to False would silently make every circuit
|
|
94
|
-
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.
|
|
95
119
|
"""
|
|
96
120
|
raw = device.get_property(node, prop)
|
|
97
121
|
if raw is None or raw == "":
|
|
@@ -125,6 +149,28 @@ def _optional_integer(device: DiscoveredDevice, node: str, prop: str) -> int | N
|
|
|
125
149
|
return None if raw is None else int(raw)
|
|
126
150
|
|
|
127
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
|
+
|
|
128
174
|
def _tabs(device: DiscoveredDevice) -> list[int]:
|
|
129
175
|
"""Breaker spaces from ``info/spaces``.
|
|
130
176
|
|
|
@@ -235,21 +281,68 @@ def relay_is_settable(device: DiscoveredDevice) -> bool:
|
|
|
235
281
|
specification's rule showing up in hardware. The vendored capture carries
|
|
236
282
|
the same pattern on its five.
|
|
237
283
|
"""
|
|
238
|
-
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))
|
|
239
318
|
|
|
240
319
|
|
|
241
320
|
def build_circuit(
|
|
242
|
-
device: DiscoveredDevice,
|
|
321
|
+
device: DiscoveredDevice,
|
|
322
|
+
device_type: str = "circuit",
|
|
323
|
+
relative_position: str = "",
|
|
324
|
+
circuit_ids: Collection[str] = frozenset(),
|
|
243
325
|
) -> SpanCircuitSnapshot:
|
|
244
|
-
"""Build one circuit snapshot from its v1.0 device.
|
|
245
|
-
raw_power = _number(device, NODE_METER, PROP_ACTIVE_POWER)
|
|
246
|
-
# Negate so positive means consumption. The guard keeps -0.0 out of the
|
|
247
|
-
# snapshot, where it would compare equal to 0.0 but format as "-0.0".
|
|
248
|
-
# A meter that has not reported stays `None` rather than becoming 0.0 W —
|
|
249
|
-
# see `SpanCircuitSnapshot` for why absent and zero must not collapse.
|
|
250
|
-
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.
|
|
251
327
|
|
|
252
|
-
|
|
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)
|
|
253
346
|
priority = _text(device, NODE_LOAD_SHED, PROP_PRIORITY, UNKNOWN)
|
|
254
347
|
priority_settable = priority_is_settable(device)
|
|
255
348
|
|
|
@@ -258,11 +351,12 @@ def build_circuit(
|
|
|
258
351
|
name=_text(device, NODE_INFO, PROP_NAME),
|
|
259
352
|
relay_state=_text(device, NODE_SWITCH, PROP_RELAY, UNKNOWN),
|
|
260
353
|
instant_power_w=instant_power_w,
|
|
261
|
-
#
|
|
262
|
-
# produced it
|
|
263
|
-
# circuit's.
|
|
264
|
-
|
|
265
|
-
|
|
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,
|
|
266
360
|
tabs=_tabs(device),
|
|
267
361
|
priority=priority,
|
|
268
362
|
# `always-on` is `not relay-controllable`, and the flat schema derived
|
|
@@ -273,10 +367,18 @@ def build_circuit(
|
|
|
273
367
|
is_never_backup=not priority_settable,
|
|
274
368
|
device_type=device_type,
|
|
275
369
|
relative_position=relative_position,
|
|
276
|
-
|
|
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,
|
|
277
378
|
current_a=_number(device, NODE_METER, PROP_CURRENT),
|
|
278
379
|
breaker_rating_a=_number(device, NODE_BREAKER, PROP_RATING),
|
|
279
|
-
|
|
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,
|
|
280
382
|
relay_requester=_text(device, NODE_SWITCH, PROP_RELAY_REQUESTER, UNKNOWN),
|
|
281
383
|
relay_state_target=device.get_property_target(NODE_SWITCH, PROP_RELAY),
|
|
282
384
|
priority_target=device.get_property_target(NODE_LOAD_SHED, PROP_PRIORITY),
|
|
@@ -291,4 +393,5 @@ def build_circuit(
|
|
|
291
393
|
# policy, both, or neither, so they are read separately and named apart.
|
|
292
394
|
pcs_managed=_optional_flag(device, NODE_PCS, PROP_MANAGED),
|
|
293
395
|
pcs_priority=_optional_integer(device, NODE_PCS, PROP_PRIORITY),
|
|
396
|
+
measures_outside_panel=outside,
|
|
294
397
|
)
|
|
@@ -8,6 +8,9 @@ look unchanged are addressed differently.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from typing import Final
|
|
13
|
+
|
|
11
14
|
# -- Device classes ---------------------------------------------------------
|
|
12
15
|
|
|
13
16
|
DEVICE_TYPE_PREFIX = "energy.ebus.device."
|
|
@@ -41,6 +44,32 @@ NODE_CONNECTION = "connection"
|
|
|
41
44
|
# imports `panel` -- so the constant has to live below both of them.
|
|
42
45
|
PROP_FEEDS_DEVICE_ID = "feeds-device-id"
|
|
43
46
|
PROP_FEEDS_DEVICE_STATUS = "feeds-device-status"
|
|
47
|
+
PROP_FEEDS_ROLE = "feeds-role"
|
|
48
|
+
PROP_SHARED_WITH_DEVICE_IDS = "shared-with-device-ids"
|
|
49
|
+
PROP_OVERCURRENT_PROTECTION = "overcurrent-protection"
|
|
50
|
+
PROP_BUSBAR_CURRENT = "busbar-current"
|
|
51
|
+
PROP_FREQUENCY = "frequency"
|
|
52
|
+
PROP_NOMINAL_VOLTAGE = "nominal-voltage"
|
|
53
|
+
PROP_PROTECTION_FUNCTIONS = "protection-functions"
|
|
54
|
+
|
|
55
|
+
SITE_PROPERTIES: Final[frozenset[str]] = frozenset(
|
|
56
|
+
{
|
|
57
|
+
"address-lines",
|
|
58
|
+
"locality",
|
|
59
|
+
"region",
|
|
60
|
+
"country-code",
|
|
61
|
+
"latitude",
|
|
62
|
+
"longitude",
|
|
63
|
+
"utility-meter-serial-number",
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
"""Property ids that say where a panel is installed, which never become a reading.
|
|
67
|
+
|
|
68
|
+
Skipped by `extension` and `adoption` on whatever node a device declares them.
|
|
69
|
+
Where the panel declares them, under `info` beside the site's `info/name`, the
|
|
70
|
+
identity node is skipped as a whole already; this holds if a device declares
|
|
71
|
+
them anywhere else. They stay discovery rows, which carry no values.
|
|
72
|
+
"""
|
|
44
73
|
PROP_FED_BY_DEVICE_ID = "fed-by-device-id"
|
|
45
74
|
PROP_FED_BY_DEVICE_STATUS = "fed-by-device-status"
|
|
46
75
|
NODE_DOOR = "door"
|
|
@@ -194,6 +223,17 @@ PANEL_SIZE_BY_MODEL: dict[str, int] = {
|
|
|
194
223
|
"MLO_48": 48,
|
|
195
224
|
}
|
|
196
225
|
|
|
226
|
+
# The first and last breaker position for each model. Keyed like
|
|
227
|
+
# `PANEL_SIZE_BY_MODEL`, and like it the numbers are ours: the panel publishes
|
|
228
|
+
# only which positions are occupied.
|
|
229
|
+
PANEL_POSITIONS_BY_MODEL: Final[Mapping[str, tuple[int, int]]] = {
|
|
230
|
+
"MAIN_16": (9, 24),
|
|
231
|
+
"MLO_24": (1, 24),
|
|
232
|
+
"MAIN_32": (1, 32),
|
|
233
|
+
"MAIN_40": (9, 48),
|
|
234
|
+
"MLO_48": (1, 48),
|
|
235
|
+
}
|
|
236
|
+
|
|
197
237
|
# The Homie attribute that carries what the flat schema published as the
|
|
198
238
|
# `never-backup` boolean. v1.0 retires the property and expresses it as
|
|
199
239
|
# mutability: a circuit commissioned never-backup has its priority locked, and
|