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.
Files changed (40) hide show
  1. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/CHANGELOG.md +8 -1
  2. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/PKG-INFO +2 -2
  3. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/pyproject.toml +4 -2
  4. {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
  5. {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
  6. {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
  7. {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
  8. {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
  9. {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
  10. {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
  11. {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
  12. {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
  13. {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
  14. {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
  15. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/.gitignore +0 -0
  16. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/README.md +0 -0
  17. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/breaker.json +0 -0
  18. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/charge-limit.json +0 -0
  19. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/connection.json +0 -0
  20. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/door.json +0 -0
  21. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/grid-forming.json +0 -0
  22. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/grid.json +0 -0
  23. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/info.json +0 -0
  24. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/load-shed.json +0 -0
  25. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/meter.json +0 -0
  26. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/pcs.json +0 -0
  27. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/power-flows.json +0 -0
  28. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/shed-forecast.json +0 -0
  29. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/shed.json +0 -0
  30. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/soc.json +0 -0
  31. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/status.json +0 -0
  32. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/catalogs/switch.json +0 -0
  33. {span_panel_api_schema_1-1.3.0b1 → span_panel_api_schema_1-1.4.0b2}/spec/registries/device-types.md +0 -0
  34. {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
  35. {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
  36. {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
  37. {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
  38. {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
  39. {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
  40. {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.7.0 or newer**, whose snapshot fields accept the unknown values this parser now reports.
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.0b1
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.7.0b1
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.0b1"
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
- "span-panel-api>=3.7.0b1,<4.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",
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 everything else has arrived.
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 as its circuit's
68
- name. One still missing this long after the rest of the tree's labels is taken
69
- as absent: a DER may be published with no connection record at all.
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._feed_wait_since: float | None = None
114
- self._feed_wait_reported = False
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
- return build_snapshot(root, self._children())
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 wait is bounded by `FEED_GRACE_S`. A DER may be published with
227
- no connection record pointing at it, and then no value is coming; a
228
- retained feed lands in the same burst as its circuit's name, so once
229
- every name, model and firmware version has arrived, a feed still
230
- unvalued after the grace is taken as absent. The inverter is then keyed
231
- by its device id, reported once at INFO rather than the transport
232
- warning after the full name timeout. The grace restarts whenever
233
- anything else is missing again, and once every inverter is placed, so
234
- an inverter that appears after another's feed has arrived, within the
235
- same wait, gets a whole grace of its own.
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 = [circuit.device_id for circuit in roles.circuits if not circuit.get_property(NODE_INFO, PROP_NAME)]
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
- missing.extend(
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._firmware_version_pending(roles))
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. `_target` is pure string
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 self._target(circuit_id, NODE_SWITCH, PROP_RELAY)
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 self._target(circuit_id, NODE_LOAD_SHED, PROP_PRIORITY)
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 self._target(self._serial_number, NODE_SHED, PROP_ASSERTED_ISLANDING_STATE)
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 self._target(device.device_id, surface.node, limit.property_id)
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 SpanCircuitSnapshot
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*, because the
92
- property exists to mark the exception (an always-on circuit). Defaulting it
93
- to False would silently make every circuit uncontrollable on a panel that
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 _flag(device, NODE_SWITCH, PROP_RELAY_CONTROLLABLE, default=True)
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, device_type: str = "circuit", relative_position: str = ""
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
- relay_controllable = _flag(device, NODE_SWITCH, PROP_RELAY_CONTROLLABLE, default=True)
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
- # The panel *imported* this energy from the circuit, so the circuit
262
- # produced it. Named from the panel's perspective, reported from the
263
- # circuit's.
264
- produced_energy_wh=_number(device, NODE_METER, PROP_IMPORTED_ENERGY),
265
- consumed_energy_wh=_number(device, NODE_METER, PROP_EXPORTED_ENERGY),
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
- is_240v=(_number(device, NODE_BREAKER, PROP_POLES) or 1) >= 2,
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
- always_on=not relay_controllable,
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