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.
Files changed (40) hide show
  1. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/CHANGELOG.md +25 -0
  2. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/PKG-INFO +2 -2
  3. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/pyproject.toml +7 -2
  4. {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
  5. {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
  6. {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
  7. {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
  8. {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
  9. {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
  10. {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
  11. {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
  12. {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
  13. {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
  14. {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
  15. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/.gitignore +0 -0
  16. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/README.md +0 -0
  17. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/breaker.json +0 -0
  18. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/charge-limit.json +0 -0
  19. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/connection.json +0 -0
  20. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/door.json +0 -0
  21. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/grid-forming.json +0 -0
  22. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/grid.json +0 -0
  23. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/info.json +0 -0
  24. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/load-shed.json +0 -0
  25. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/meter.json +0 -0
  26. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/pcs.json +0 -0
  27. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/power-flows.json +0 -0
  28. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/shed-forecast.json +0 -0
  29. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/shed.json +0 -0
  30. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/soc.json +0 -0
  31. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/status.json +0 -0
  32. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/catalogs/switch.json +0 -0
  33. {span_panel_api_schema_1-1.2.2 → span_panel_api_schema_1-1.4.0b1}/spec/registries/device-types.md +0 -0
  34. {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
  35. {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
  36. {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
  37. {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
  38. {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
  39. {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
  40. {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.2.2
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.6.2
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.2.2"
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
- "span-panel-api>=3.6.2,<4.0",
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 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]] = []
@@ -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 erases every unmapped position rather
171
- than merely mis-stating a number. Asking only for what the panel itself
172
- promised keeps a firmware that omits the property connectable — it
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
- 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,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: ``priority != NEVER and relay-controllable``
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 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
+ )
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
- PRIORITY_NEVER,
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*, because the
83
- property exists to mark the exception (an always-on circuit). Defaulting it
84
- to False would silently make every circuit uncontrollable on a panel that
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 _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))
230
318
 
231
319
 
232
320
  def build_circuit(
233
- 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(),
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
- 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)
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
- # The panel *imported* this energy from the circuit, so the circuit
253
- # produced it. Named from the panel's perspective, reported from the
254
- # circuit's.
255
- produced_energy_wh=_number(device, NODE_METER, PROP_IMPORTED_ENERGY),
256
- 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,
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 != PRIORITY_NEVER and relay_controllable,
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
- 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,
268
378
  current_a=_number(device, NODE_METER, PROP_CURRENT),
269
379
  breaker_rating_a=_number(device, NODE_BREAKER, PROP_RATING),
270
- 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,
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
  )