span-panel-api-schema-1 1.1.4__tar.gz → 1.2.1__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.1.4 → span_panel_api_schema_1-1.2.1}/CHANGELOG.md +32 -0
  2. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/PKG-INFO +2 -2
  3. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/pyproject.toml +7 -2
  4. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/src/span_panel_api_schema_1/adapter.py +103 -4
  5. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/src/span_panel_api_schema_1/devices.py +112 -49
  6. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/src/span_panel_api_schema_1/field_metadata.py +6 -5
  7. span_panel_api_schema_1-1.2.1/src/span_panel_api_schema_1/firmware.py +34 -0
  8. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/src/span_panel_api_schema_1/snapshot.py +74 -12
  9. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/.gitignore +0 -0
  10. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/README.md +0 -0
  11. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/spec/catalogs/breaker.json +0 -0
  12. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/spec/catalogs/charge-limit.json +0 -0
  13. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/spec/catalogs/connection.json +0 -0
  14. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/spec/catalogs/door.json +0 -0
  15. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/spec/catalogs/grid-forming.json +0 -0
  16. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/spec/catalogs/grid.json +0 -0
  17. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/spec/catalogs/info.json +0 -0
  18. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/spec/catalogs/load-shed.json +0 -0
  19. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/spec/catalogs/meter.json +0 -0
  20. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/spec/catalogs/pcs.json +0 -0
  21. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/spec/catalogs/power-flows.json +0 -0
  22. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/spec/catalogs/shed-forecast.json +0 -0
  23. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/spec/catalogs/shed.json +0 -0
  24. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/spec/catalogs/soc.json +0 -0
  25. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/spec/catalogs/status.json +0 -0
  26. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/spec/catalogs/switch.json +0 -0
  27. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/spec/registries/device-types.md +0 -0
  28. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/src/span_panel_api_schema_1/__init__.py +0 -0
  29. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/src/span_panel_api_schema_1/adoption.py +0 -0
  30. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/src/span_panel_api_schema_1/catalog.py +0 -0
  31. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/src/span_panel_api_schema_1/charge_limit.py +0 -0
  32. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/src/span_panel_api_schema_1/circuits.py +0 -0
  33. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/src/span_panel_api_schema_1/const.py +0 -0
  34. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/src/span_panel_api_schema_1/description.py +0 -0
  35. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/src/span_panel_api_schema_1/extension.py +0 -0
  36. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/src/span_panel_api_schema_1/panel.py +0 -0
  37. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/src/span_panel_api_schema_1/py.typed +0 -0
  38. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/src/span_panel_api_schema_1/reference/parent_child_tree.json +0 -0
  39. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/src/span_panel_api_schema_1/spec_lock.json +0 -0
  40. {span_panel_api_schema_1-1.1.4 → span_panel_api_schema_1-1.2.1}/src/span_panel_api_schema_1/transport.py +0 -0
@@ -9,6 +9,38 @@ number. A release here means this parser changed, never that the panel did.
9
9
 
10
10
  Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the last public release, never against the beta before it.
11
11
 
12
+ ## [1.2.1]
13
+
14
+ Every PV inverter a panel commissions is read, and the battery's power keeps its sign on firmware r202639. Requires `span-panel-api` **3.6.1 or newer**.
15
+
16
+ ### Added
17
+
18
+ - **Every PV inverter is read, not only the first**, each landing in `pv_inverters` with its serial number when published, its device id and its key.
19
+ - **`pv.serial_number` is read from `info/serial-number`**, so that property no longer appears as a discovery row.
20
+ - **`TreeRoles.pvs` and `devices.pv_inverter_key`** list every inverter and give an inverter's key.
21
+ - **`span_panel_api_schema_1.firmware.release_build`** reads the six-digit release build number out of a firmware version string, or returns `None` when it has none.
22
+ - **`build_battery` takes keyword-only `firmware_version` and `power_flow_battery`** to pick the BESS meter's wire frame, and `SchemaOneAdapter` a keyword-only `clock` for the wait on feeding circuits, and both behave as before without them.
23
+
24
+ ### Changed
25
+
26
+ - **`pv` describes the inverters together when a panel commissions more than one**, with their shared vendor and model, the sum of their installed DC sizes and a link that is down if any is down, rather than whichever came first in the device tree.
27
+
28
+ ### Removed
29
+
30
+ - **`TreeRoles.pv`**, which held only the first inverter, replaced by `TreeRoles.pvs`.
31
+
32
+ ### Fixed
33
+
34
+ - **Every inverter's feeding circuit is labeled `device_type="pv"`**, so a second inverter's power no longer reads as a load with the opposite sign.
35
+ - **A PV inverter or charger wired through two breakers reports its power and link status consistently**, with both circuits labeled for it and its feed and link status read from the one with the lowest circuit id.
36
+ - **Each inverter's extension properties are filed under that inverter**, with its `pv_inverters` key as the `instance_key` when more than one is commissioned.
37
+ - **The first snapshot waits briefly for the circuits that feed an inverter**, so an inverter's key and its circuit's label do not change just after connecting.
38
+ - **`battery.power_w` is discharge-positive on firmware r202639 and later, from the first snapshot on**, where a charging battery read positive.
39
+
40
+ ## [1.2.0] [YANKED]
41
+
42
+ Withdrawn from PyPI; 1.2.1 replaces it.
43
+
12
44
  ## [1.1.4]
13
45
 
14
46
  The parser reports the islanding state the panel is acting on, and refuses islanding writes the panel would ignore. Reported and fixed by [@dcj](https://github.com/dcj) in [#179](https://github.com/SpanPanel/span-panel-api/pull/179), from issue
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api-schema-1
3
- Version: 1.1.4
3
+ Version: 1.2.1
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.1.0
11
+ Requires-Dist: span-panel-api<4.0,>=3.6.1
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.1.4"
3
+ version = "1.2.1"
4
4
  description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -21,7 +21,12 @@ dependencies = [
21
21
  # bootstrap defining everything this parser imports. 3.1.0 adds `ControlTarget`
22
22
  # and replaces the `set_*_topic` members with `set_*_target`, so this wheel is
23
23
  # rejected at discovery by any 3.0.x.
24
- "span-panel-api>=3.1.0,<4.0",
24
+ # Raised to 3.6.0, the first bootstrap whose snapshot carries `pv_inverters`
25
+ # and the inverter's `serial_number`, `device_id` and `node_id`, which this
26
+ # parser fills. Against an earlier one, building a snapshot fails on those
27
+ # arguments.
28
+ # Raised to 3.6.1, whose SpanPanelSnapshot.pv documents what this parser builds.
29
+ "span-panel-api>=3.6.1,<4.0",
25
30
  # Only this distribution depends on the eBus SDK. The bootstrap and
26
31
  # schema-0 stay clean, so a flat-panel install never pulls it in — which is
27
32
  # what bounds the release coupling this dependency introduces to panels on
@@ -18,7 +18,8 @@ tree, and the SDK repopulates — so there is no resync hook to wire or forget.
18
18
  from __future__ import annotations
19
19
 
20
20
  import logging
21
- from typing import TYPE_CHECKING
21
+ import time
22
+ from typing import TYPE_CHECKING, Final
22
23
 
23
24
  from ebus_sdk import Controller
24
25
 
@@ -28,11 +29,15 @@ from span_panel_api_schema_1.circuits import priority_is_settable, relay_is_sett
28
29
  from span_panel_api_schema_1.const import (
29
30
  HOMIE_DOMAIN,
30
31
  HOMIE_VERSION,
32
+ NODE_CONNECTION,
31
33
  NODE_INFO,
32
34
  NODE_LOAD_SHED,
33
35
  NODE_SHED,
34
36
  NODE_SWITCH,
35
37
  PROP_ASSERTED_ISLANDING_STATE,
38
+ PROP_FED_BY_DEVICE_ID,
39
+ PROP_FEEDS_DEVICE_ID,
40
+ PROP_FIRMWARE_VERSION,
36
41
  PROP_MODEL,
37
42
  PROP_NAME,
38
43
  PROP_PRIORITY,
@@ -41,8 +46,9 @@ from span_panel_api_schema_1.const import (
41
46
  TYPE_CIRCUIT,
42
47
  )
43
48
  from span_panel_api_schema_1.description import declared_settable, device_type, node_properties
49
+ from span_panel_api_schema_1.devices import feed_circuit_ids
44
50
  from span_panel_api_schema_1.field_metadata import build_field_metadata
45
- from span_panel_api_schema_1.panel import integer
51
+ from span_panel_api_schema_1.panel import integer, text
46
52
  from span_panel_api_schema_1.snapshot import TreeRoles, build_snapshot, harmonised_evse_keys
47
53
  from span_panel_api_schema_1.transport import ControllerRoutes
48
54
 
@@ -55,6 +61,33 @@ if TYPE_CHECKING:
55
61
 
56
62
  _LOGGER = logging.getLogger(__name__)
57
63
 
64
+ FEED_GRACE_S: Final = 2.0
65
+ """How long an inverter's feed may stay unvalued once everything else has arrived.
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.
70
+ """
71
+
72
+
73
+ def _unvalued_feeds_for_unplaced_pvs(roles: TreeRoles) -> list[str]:
74
+ """Circuits whose feed has not arrived, while an inverter has no known source.
75
+
76
+ Empty when every inverter is placed, by a circuit's
77
+ `connection/feeds-device-id` or a lugs device's `connection/fed-by-device-id`:
78
+ no value still to arrive can then change any inverter's key.
79
+ """
80
+ placed = set(feed_circuit_ids(roles.circuits))
81
+ placed.update(text(lugs, NODE_CONNECTION, PROP_FED_BY_DEVICE_ID) for lugs in roles.lugs)
82
+ if all(pv.device_id in placed for pv in roles.pvs):
83
+ return []
84
+ return [
85
+ circuit.device_id
86
+ for circuit in roles.circuits
87
+ if PROP_FEEDS_DEVICE_ID in circuit.get_node_properties(NODE_CONNECTION)
88
+ and circuit.get_property(NODE_CONNECTION, PROP_FEEDS_DEVICE_ID) is None
89
+ ]
90
+
58
91
 
59
92
  class SchemaOneAdapter:
60
93
  """Parser for the parent/child schema (data-model-version 1.x)."""
@@ -67,9 +100,18 @@ class SchemaOneAdapter:
67
100
  schema_major = "schema_1"
68
101
  SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = (">=1.0", "<2.0")
69
102
 
70
- def __init__(self, serial_number: str, schema: V2HomieSchema) -> None:
103
+ def __init__(
104
+ self,
105
+ serial_number: str,
106
+ schema: V2HomieSchema,
107
+ *,
108
+ clock: Callable[[], float] = time.monotonic,
109
+ ) -> None:
71
110
  self._serial_number = serial_number
72
111
  self._schema = schema
112
+ self._clock = clock
113
+ self._feed_wait_since: float | None = None
114
+ self._feed_wait_reported = False
73
115
  self._routes = ControllerRoutes()
74
116
  self._controller = Controller(root_device_id=serial_number, mqttc=self._routes)
75
117
  self._property_callbacks: list[Callable[[str, str, str, str | None], None]] = []
@@ -169,10 +211,30 @@ class SchemaOneAdapter:
169
211
  way to get a placeholder. Under parent/child every mapped device has
170
212
  the same exposure, so a DER missing the model it declared is reported
171
213
  alongside a circuit missing its name.
214
+
215
+ An inverter's feeding circuit is waited on too, because it decides the
216
+ inverter's `pv_inverters` key, and a key that moved after the first
217
+ snapshot would move every entity built on it. A circuit feeding nothing
218
+ publishes no `connection/feeds-device-id` value at all, so an unvalued
219
+ one is ambiguous; it is reported only while some inverter is still
220
+ unplaced (no circuit feeds it and no lugs device names it as its
221
+ source), which is the one case its value could still change a key.
222
+
223
+ The panel itself is reported while a declared BESS waits on the
224
+ panel's declared firmware version, which decides the BESS meter's frame.
225
+
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.
172
234
  """
173
235
  roles = TreeRoles(self._children())
174
236
  missing = [circuit.device_id for circuit in roles.circuits if not circuit.get_property(NODE_INFO, PROP_NAME)]
175
- ders = (roles.bess, roles.pv, *roles.evse)
237
+ ders = (roles.bess, *roles.pvs, *roles.evse)
176
238
  missing.extend(
177
239
  device.device_id
178
240
  for device in ders
@@ -180,8 +242,45 @@ class SchemaOneAdapter:
180
242
  and PROP_MODEL in device.get_node_properties(NODE_INFO)
181
243
  and device.get_property(NODE_INFO, PROP_MODEL) is None
182
244
  )
245
+ missing.extend(self._firmware_version_pending(roles))
246
+ feeds = _unvalued_feeds_for_unplaced_pvs(roles)
247
+ if feeds and not missing:
248
+ now = self._clock()
249
+ if self._feed_wait_since is None:
250
+ self._feed_wait_since = now
251
+ if now - self._feed_wait_since >= FEED_GRACE_S:
252
+ if not self._feed_wait_reported:
253
+ _LOGGER.info(
254
+ "No connection record names %d circuit(s) as an inverter's feed; "
255
+ "inverters without one are keyed by device id",
256
+ len(feeds),
257
+ )
258
+ self._feed_wait_reported = True
259
+ feeds = []
260
+ elif missing:
261
+ self._feed_wait_since = None
262
+ missing.extend(feeds)
183
263
  return missing
184
264
 
265
+ def _firmware_version_pending(self, roles: TreeRoles) -> list[str]:
266
+ """The panel itself, while a declared BESS waits on the panel's firmware version.
267
+
268
+ The release build in the panel's `info/firmware-version` decides which
269
+ frame the BESS meter is read in. A first snapshot built before it lands
270
+ falls back to comparing signs with `power-flows/battery`, and a later one
271
+ may read the other way, so the battery's power would flip once after
272
+ connect. Only a declared property is waited on: a panel that does not
273
+ promise one is never held for it.
274
+ """
275
+ root = self._controller.get_root(self._serial_number)
276
+ if roles.bess is None or root is None:
277
+ return []
278
+ if PROP_FIRMWARE_VERSION not in root.get_node_properties(NODE_INFO):
279
+ return []
280
+ if root.get_property(NODE_INFO, PROP_FIRMWARE_VERSION) is None:
281
+ return [root.device_id]
282
+ return []
283
+
185
284
  def find_node_by_type(self, type_str: str) -> str | None:
186
285
  """Return the id of the first device declaring `type_str`.
187
286
 
@@ -19,36 +19,33 @@ against conflating them. Both are now carried, in separate fields
19
19
  (``connected`` and ``communication_state``), because they answer different
20
20
  questions: the panel's view of the link, and the publisher's view of its own.
21
21
 
22
- **Battery power is discharge-positive, and that is not the rule circuits follow.**
23
- ``build_circuit`` negates so that positive means power flowing *into* the metered
24
- device, which is the convention the rest of this module states. ``build_battery``
25
- negates too, but its wire input is in the opposite frame, so it lands on the
26
- opposite result: ``battery.power_w`` is positive while the battery *discharges*.
27
-
28
- Measured rather than reasoned. Driving the producer into self-consumption with
29
- the grid at zero forces the direction: PV 4181 W plus battery 1917 W meeting a
30
- 6099 W load leaves nothing ambiguous, and the battery is discharging. The wire
22
+ **Battery power is discharge-positive, and the wire's frame for it changed at r202639.**
23
+ ``battery.power_w`` is positive while the battery *discharges*: the frame the eBus
24
+ specification asks for from a device's own meter, and deliberately not the
25
+ into-the-device rule ``build_circuit`` follows.
26
+
27
+ Through r202633 the BESS ``meter/active-power`` is charge-positive, carrying the
28
+ same sign as the enclosure's ``power-flows/battery``, so ``build_battery`` negates
29
+ it. Measured rather than reasoned: driving the producer into self-consumption
30
+ with the grid at zero forces the direction. PV 4181 W plus battery 1917 W meeting
31
+ a 6099 W load leaves nothing ambiguous: the battery is discharging. The wire
31
32
  publishes ``-1917.49`` and this module reports ``+1917.49``.
32
33
 
33
- That value is *correct* -- it is the frame the eBus specification asks for from a
34
- device's own meter, "positive while discharging, that is, power flowing out of
35
- the device". What was wrong was the name: this used to be ``_charge_positive``,
36
- and the sentence above used to claim the into-the-device rule held everywhere.
37
- It does not hold for the battery, and saying so was the defect.
38
-
39
- Both wire properties behind it carry the *same* sign as each other -- a live
40
- panel capture and ``ebus-panel-sim`` 0.6.0 both publish the pair identically --
41
- so ``panel.power_flow_battery`` (passed through untouched) and
42
- ``battery.power_w`` (negated here) end up as each other's mirror, and a consumer
43
- showing both sees one convention after applying one negation to either.
44
-
45
- Note that the alignment of those two wire properties is the specification's
46
- *violation* rather than its rule: the spec defines ``power-flows/battery`` as the
47
- negation of the BESS meter, and this firmware publishes them equal. Comparing the
48
- two therefore tells a consumer which firmware it is on -- equal means today's,
49
- opposite means a conformant future one -- which is what would let this conversion
50
- stay correct across that change. Undecidable while the battery is idle and both
51
- read zero.
34
+ From r202639 the BESS meter is published discharge-positive, the negation of
35
+ ``power-flows/battery`` (whose own sign did not change), so it passes through.
36
+ The frame is chosen from the enclosure's release build number
37
+ (``firmware.release_build``), never from the respin suffix after it. Where the
38
+ version does not parse, the two wire properties decide instead: one nonzero sign
39
+ on both is the old frame, opposite signs the new one. Where either is zero or
40
+ absent the old frame is assumed, because that is what every earlier release of
41
+ this package applied and what the reference capture (``ebus-panel-sim`` 0.8.0,
42
+ which publishes no SPAN version string) carries. A battery at rest reads zero in
43
+ both frames, so the assumption only decides a nonzero BESS meter beside a missing
44
+ or zero flow figure.
45
+
46
+ On either firmware ``panel.power_flow_battery`` (passed through untouched, and
47
+ charge-positive) and ``battery.power_w`` end up as each other's mirror, so a
48
+ consumer showing both sees one convention after negating either one.
52
49
  """
53
50
 
54
51
  from __future__ import annotations
@@ -78,6 +75,7 @@ from span_panel_api_schema_1.const import (
78
75
  PROP_VENDOR_NAME,
79
76
  UNKNOWN,
80
77
  )
78
+ from span_panel_api_schema_1.firmware import release_build
81
79
  from span_panel_api_schema_1.panel import integer, number, resolve_grid_forming_device_name, text
82
80
 
83
81
  if TYPE_CHECKING:
@@ -114,12 +112,14 @@ def feed_circuit_ids(circuits: list[DiscoveredDevice]) -> dict[str, str]:
114
112
 
115
113
  v1.0 states the relationship on the *circuit* (``connection/feeds-device-id``)
116
114
  rather than on the DER, so it is read once here and handed to whichever
117
- device needs it.
115
+ device needs it. A device fed by more than one circuit (the specification
116
+ allows it) maps to the circuit with the lowest device id, so the answer, and
117
+ every key built on it, does not depend on discovery order.
118
118
  """
119
119
  feeds: dict[str, str] = {}
120
- for circuit in circuits:
120
+ for circuit in sorted(circuits, key=lambda device: device.device_id):
121
121
  fed = text(circuit, NODE_CONNECTION, PROP_FEEDS_DEVICE_ID)
122
- if fed:
122
+ if fed and fed not in feeds:
123
123
  feeds[fed] = circuit.device_id
124
124
  return feeds
125
125
 
@@ -157,12 +157,18 @@ def feed_connection_statuses(circuits: list[DiscoveredDevice]) -> dict[str, str]
157
157
  Most circuits publish neither. A mixed-load or unsurveyed circuit feeds no
158
158
  commissioned DER, so it has no connection record to publish — the spec calls
159
159
  that normal, which is why nothing here treats a missing record as an error.
160
+
161
+ A device fed by more than one circuit takes the status of the circuit
162
+ ``feed_circuit_ids`` chose for it, so its link health and its feed come from
163
+ one record whatever the discovery order. That circuit's silence is not
164
+ filled from the other feed: it leaves the device absent here, as any
165
+ circuit publishing only one half does.
160
166
  """
167
+ by_id = {circuit.device_id: circuit for circuit in circuits}
161
168
  statuses: dict[str, str] = {}
162
- for circuit in circuits:
163
- fed = text(circuit, NODE_CONNECTION, PROP_FEEDS_DEVICE_ID)
164
- status = text(circuit, NODE_CONNECTION, PROP_FEEDS_DEVICE_STATUS)
165
- if fed and status:
169
+ for fed, circuit_id in feed_circuit_ids(circuits).items():
170
+ status = text(by_id[circuit_id], NODE_CONNECTION, PROP_FEEDS_DEVICE_STATUS)
171
+ if status:
166
172
  statuses[fed] = status
167
173
  return statuses
168
174
 
@@ -185,32 +191,71 @@ def _connected(status: str | None) -> bool | None:
185
191
  return None if status is None else status == STATUS_OK
186
192
 
187
193
 
188
- def _discharge_positive(raw_power_w: float | None) -> float | None:
189
- """Flip the enclosure's meter frame to the BESS device's own.
194
+ BESS_METER_DISCHARGE_POSITIVE_FROM_BUILD = 202639
195
+ """The first release build whose BESS ``meter/active-power`` is discharge-positive."""
196
+
197
+
198
+ def _bess_meter_is_charge_positive(
199
+ firmware_version: str | None,
200
+ raw_power_w: float | None,
201
+ power_flow_battery: float | None,
202
+ ) -> bool:
203
+ """Whether the BESS meter on the wire is in the pre-r202639 frame.
204
+
205
+ The enclosure's release build decides when it parses. Otherwise the BESS
206
+ meter is compared with ``power-flows/battery``, which kept its sign across
207
+ the change: equal signs mean the old frame, opposite signs the new one. With
208
+ either one zero or absent the comparison says nothing, and the old frame is
209
+ assumed (see the module docstring for why).
210
+ """
211
+ build = release_build(firmware_version)
212
+ if build is not None:
213
+ return build < BESS_METER_DISCHARGE_POSITIVE_FROM_BUILD
214
+ if raw_power_w is None or power_flow_battery is None or raw_power_w == 0.0 or power_flow_battery == 0.0:
215
+ return True
216
+ return (raw_power_w > 0.0) == (power_flow_battery > 0.0)
217
+
218
+
219
+ def _discharge_positive(raw_power_w: float | None, *, charge_positive_on_wire: bool) -> float | None:
220
+ """Bring the BESS meter into the device's own frame, discharge-positive.
190
221
 
191
222
  Positive means power flowing *out of the battery*, which is discharging, and
192
- which is what the eBus specification asks of a device's own meter. Named for
193
- what it produces after being called `_charge_positive` for as long as it
194
- produced the opposite -- measured against a producer driven into
195
- self-consumption, where the grid sits at zero and the direction cannot be
196
- argued.
223
+ which is what the eBus specification asks of a device's own meter. A wire
224
+ value already in that frame passes through; one in the older charge-positive
225
+ frame is negated.
197
226
 
198
227
  `None` stays `None`: a BESS that publishes no `meter` node has no power
199
228
  reading, which is not the same as zero. The `0.0` guard is `build_circuit`'s,
200
- for the same reason — negating `0.0` yields `-0.0`, which compares equal to
201
- `0.0` and formats as `"-0.0"`.
229
+ for the same reason: negating `0.0` yields `-0.0`, which compares equal to
230
+ `0.0` and formats as `"-0.0"`. A wire `-0.0` is normalized on the
231
+ pass-through path too.
202
232
  """
203
233
  if raw_power_w is None:
204
234
  return None
205
- return 0.0 if raw_power_w == 0.0 else -raw_power_w
235
+ if raw_power_w == 0.0:
236
+ return 0.0
237
+ return -raw_power_w if charge_positive_on_wire else raw_power_w
206
238
 
207
239
 
208
- def build_battery(bess: DiscoveredDevice | None, owners: list[DiscoveredDevice]) -> SpanBatterySnapshot:
209
- """Build the battery snapshot. An uncommissioned panel yields the empty one."""
240
+ def build_battery(
241
+ bess: DiscoveredDevice | None,
242
+ owners: list[DiscoveredDevice],
243
+ *,
244
+ firmware_version: str | None = None,
245
+ power_flow_battery: float | None = None,
246
+ ) -> SpanBatterySnapshot:
247
+ """Build the battery snapshot. An uncommissioned panel yields the empty one.
248
+
249
+ `firmware_version` is the enclosure's ``info/firmware-version`` and
250
+ `power_flow_battery` its ``power-flows/battery``; together they pick the
251
+ BESS meter's wire frame. Omitted, they leave the old frame assumed.
252
+ """
210
253
  if bess is None:
211
254
  return SpanBatterySnapshot()
212
255
 
213
256
  status = connection_status_for(bess.device_id, owners)
257
+ raw_power_w = number(bess, NODE_METER, PROP_ACTIVE_POWER)
258
+ charge_positive_on_wire = _bess_meter_is_charge_positive(firmware_version, raw_power_w, power_flow_battery)
214
259
 
215
260
  return SpanBatterySnapshot(
216
261
  # Historically misnamed in the snapshot and kept that way: `soe_percentage`
@@ -229,7 +274,7 @@ def build_battery(bess: DiscoveredDevice | None, owners: list[DiscoveredDevice])
229
274
  nameplate_capacity_kwh=number(bess, NODE_INFO, PROP_NAMEPLATE_CAPACITY),
230
275
  # None when unclaimed, so "nobody has said" stays distinct from "not OK".
231
276
  connected=_connected(status),
232
- power_w=_discharge_positive(number(bess, NODE_METER, PROP_ACTIVE_POWER)),
277
+ power_w=_discharge_positive(raw_power_w, charge_positive_on_wire=charge_positive_on_wire),
233
278
  # The BESS's own link health, kept as the published enum string rather
234
279
  # than collapsed to a bool: DEGRADED is neither OK nor LOST, and a bool
235
280
  # would have to pick one.
@@ -237,6 +282,17 @@ def build_battery(bess: DiscoveredDevice | None, owners: list[DiscoveredDevice])
237
282
  )
238
283
 
239
284
 
285
+ def pv_inverter_key(pv: DiscoveredDevice, feeds: Mapping[str, str]) -> str:
286
+ """The inverter's `pv_inverters` key: its feeding circuit's id, else its device id.
287
+
288
+ The circuit first because its id survives firmware r202639, which renumbers
289
+ every PV device on a panel with more than one inverter. A circuit feeds at
290
+ most one device, so two circuit-fed inverters cannot share a key. An
291
+ inverter no circuit feeds keeps its device id, the only handle it has.
292
+ """
293
+ return feeds.get(pv.device_id) or pv.device_id
294
+
295
+
240
296
  def build_pv(
241
297
  pv: DiscoveredDevice | None,
242
298
  feeds: dict[str, str],
@@ -245,7 +301,11 @@ def build_pv(
245
301
  *,
246
302
  feed_statuses: dict[str, str],
247
303
  ) -> SpanPVSnapshot:
248
- """Build the PV snapshot. An uncommissioned panel yields the empty one."""
304
+ """Build one inverter's PV snapshot. An uncommissioned panel yields the empty one.
305
+
306
+ `info/nominal-power` is the array's DC size as recorded at installation,
307
+ carried for display and never a limit on what the inverter produces.
308
+ """
249
309
  if pv is None:
250
310
  return SpanPVSnapshot()
251
311
 
@@ -253,9 +313,12 @@ def build_pv(
253
313
  connected=_connected(feed_statuses.get(pv.device_id)),
254
314
  vendor_name=_optional(text(pv, NODE_INFO, PROP_VENDOR_NAME)),
255
315
  model=_optional(text(pv, NODE_INFO, PROP_MODEL)),
316
+ serial_number=_optional(text(pv, NODE_INFO, PROP_SERIAL_NUMBER)),
256
317
  software_version=_optional(text(pv, NODE_INFO, PROP_FIRMWARE_VERSION)),
257
318
  nameplate_capacity_w=number(pv, NODE_INFO, PROP_NOMINAL_POWER),
258
319
  feed_circuit_id=feeds.get(pv.device_id),
320
+ device_id=pv.device_id,
321
+ node_id=pv_inverter_key(pv, feeds),
259
322
  # Retired as a property in v1.0 and derived instead, per the enclosure model's
260
323
  # own replacement rule. `None` where no owner references the DER, because the
261
324
  # integration gates whether a control entity exists on this value.
@@ -157,11 +157,11 @@ _PROPERTY_FIELD_MAP: tuple[tuple[str, str, str, str], ...] = (
157
157
  (TYPE_BESS, NODE_INFO, "serial-number", "battery.serial_number"),
158
158
  (TYPE_BESS, NODE_INFO, "firmware-version", "battery.software_version"),
159
159
  (TYPE_BESS, NODE_INFO, "nameplate-capacity", "battery.nameplate_capacity_kwh"),
160
- # The BESS's own meter and its own link health. `battery.power_w` carries a
161
- # sign flip (`build_battery` reports discharge-positive, the wire carries the
162
- # enclosure's frame), which does not affect the unit or the datatype this row
163
- # describes — a row states what the property *is*, not what the mapper does
164
- # with it.
160
+ # The BESS's own meter and its own link health. `battery.power_w` may carry a
161
+ # sign flip (`build_battery` reports discharge-positive, and the wire is
162
+ # charge-positive before r202639), which does not affect the unit or the
163
+ # datatype this row describes: a row states what the property *is*, not what
164
+ # the mapper does with it.
165
165
  (TYPE_BESS, NODE_METER, "active-power", "battery.power_w"),
166
166
  (TYPE_BESS, NODE_STATUS, "communication-state", "battery.communication_state"),
167
167
  # --- PV ------------------------------------------------------------------
@@ -457,6 +457,7 @@ _CONSUMED_WITHOUT_A_ROW: tuple[tuple[str, str, str], ...] = (
457
457
  (TYPE_EVSE, NODE_INFO, "serial-number"),
458
458
  (TYPE_EVSE, NODE_INFO, "vendor-name"),
459
459
  (TYPE_PV, NODE_INFO, "firmware-version"),
460
+ (TYPE_PV, NODE_INFO, "serial-number"),
460
461
  )
461
462
  """Declarations the mapper reads into the snapshot without a metadata row.
462
463
 
@@ -0,0 +1,34 @@
1
+ """Read the release build number out of the enclosure's firmware version string.
2
+
3
+ SPAN firmware publishes ``info/firmware-version`` as slash-separated segments,
4
+ for example ``spanos2/r202639/03``. The ``rNNNNNN`` segment names the release and
5
+ is the only part a behavior gate may compare: the trailing ``/03`` is a respin of
6
+ that release, and two respins of one release publish the same wire conventions.
7
+
8
+ Kept to one function so every gate in this package parses the string the same
9
+ way, and so a string this package does not recognize yields `None` rather than a
10
+ guess. A caller decides what `None` means for its own question.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ _RELEASE_SEGMENT = re.compile(r"r(\d{6})")
18
+
19
+
20
+ def release_build(firmware_version: str | None) -> int | None:
21
+ """The six-digit release build number, or `None` when there is none to read.
22
+
23
+ ``"spanos2/r202639/03"`` gives ``202639``. Only a whole path segment of the
24
+ form ``r`` plus exactly six digits counts, so ``"r2026390"``, ``"rc1"`` and a
25
+ simulator's ``"example/v0.1.0"`` all give `None`. The first such segment wins;
26
+ the respin suffix after it is never read.
27
+ """
28
+ if firmware_version is None:
29
+ return None
30
+ for segment in firmware_version.strip().split("/"):
31
+ match = _RELEASE_SEGMENT.fullmatch(segment)
32
+ if match is not None:
33
+ return int(match.group(1))
34
+ return None
@@ -11,11 +11,13 @@ from __future__ import annotations
11
11
  import time
12
12
  from typing import TYPE_CHECKING
13
13
 
14
- from span_panel_api.models import ExtensionSubject, SpanPanelSnapshot
14
+ from span_panel_api.models import ExtensionSubject, SpanPanelSnapshot, SpanPVSnapshot
15
15
  from span_panel_api_schema_1.adoption import build_adopted_devices
16
16
  from span_panel_api_schema_1.circuits import build_circuit
17
17
  from span_panel_api_schema_1.const import (
18
+ NODE_CONNECTION,
18
19
  NODE_INFO,
20
+ PROP_FEEDS_DEVICE_ID,
19
21
  PROP_MODEL,
20
22
  PROP_SERIAL_NUMBER,
21
23
  TYPE_BESS,
@@ -34,6 +36,7 @@ from span_panel_api_schema_1.devices import (
34
36
  build_pv,
35
37
  feed_circuit_ids,
36
38
  feed_connection_statuses,
39
+ pv_inverter_key,
37
40
  )
38
41
  from span_panel_api_schema_1.extension import build_extension_properties
39
42
  from span_panel_api_schema_1.field_metadata import addressed_rows
@@ -71,7 +74,10 @@ class TreeRoles:
71
74
  self.lugs: list[DiscoveredDevice] = []
72
75
  self.evse: list[DiscoveredDevice] = []
73
76
  self.bess: DiscoveredDevice | None = None
74
- self.pv: DiscoveredDevice | None = None
77
+ # Every inverter, in tree order. From r202639 each commissioned inverter
78
+ # is its own device; earlier firmware published one. None of them is
79
+ # primary.
80
+ self.pvs: list[DiscoveredDevice] = []
75
81
  self.mid: DiscoveredDevice | None = None
76
82
 
77
83
  for device in devices:
@@ -84,12 +90,44 @@ class TreeRoles:
84
90
  self.evse.append(device)
85
91
  elif declared == TYPE_BESS and self.bess is None:
86
92
  self.bess = device
87
- elif declared == TYPE_PV and self.pv is None:
88
- self.pv = device
93
+ elif declared == TYPE_PV:
94
+ self.pvs.append(device)
89
95
  elif declared == TYPE_MID and self.mid is None:
90
96
  self.mid = device
91
97
 
92
98
 
99
+ def _pv_for(inverters: Sequence[SpanPVSnapshot]) -> SpanPVSnapshot:
100
+ """`SpanPanelSnapshot.pv`: the lone inverter, or the inverters together.
101
+
102
+ No inverter stands for the others. With several, `pv` identifies none of
103
+ them -- no device id, key, serial, firmware, feeding circuit or position --
104
+ and carries only what describes them together: the vendor and model where
105
+ every inverter shares one, the sum of their installed DC sizes where every
106
+ inverter publishes one, and their link as a three-valued AND (down if any
107
+ is reported down, up if every one is reported up, unknown otherwise).
108
+ Independent of order.
109
+ """
110
+ if not inverters:
111
+ return SpanPVSnapshot()
112
+ if len(inverters) == 1:
113
+ return inverters[0]
114
+ vendors = {inverter.vendor_name for inverter in inverters}
115
+ models = {inverter.model for inverter in inverters}
116
+ nameplates = [inverter.nameplate_capacity_w for inverter in inverters if inverter.nameplate_capacity_w is not None]
117
+ links = [inverter.connected for inverter in inverters]
118
+ connected: bool | None = None
119
+ if any(link is False for link in links):
120
+ connected = False
121
+ elif all(link is True for link in links):
122
+ connected = True
123
+ return SpanPVSnapshot(
124
+ vendor_name=vendors.pop() if len(vendors) == 1 else None,
125
+ model=models.pop() if len(models) == 1 else None,
126
+ nameplate_capacity_w=sum(nameplates) if len(nameplates) == len(inverters) else None,
127
+ connected=connected,
128
+ )
129
+
130
+
93
131
  def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], ready_since: float = 0.0) -> SpanPanelSnapshot:
94
132
  """Build a full snapshot from the panel and its descendants."""
95
133
  roles = TreeRoles(children)
@@ -104,11 +142,16 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re
104
142
  feed_statuses = feed_connection_statuses(roles.circuits)
105
143
  # A DER's device type decides how its feeding circuit is labelled, so the
106
144
  # circuit inherits it — matching the flat adapter, where the same circuit
107
- # reports device_type "pv" rather than "circuit".
145
+ # reports device_type "pv" rather than "circuit". Every inverter's circuit,
146
+ # however many are commissioned: a PV circuit left labeled "circuit" is
147
+ # read as a load, with its power in the opposite sign. Read from the
148
+ # circuit side rather than through `feeds`, which keeps one circuit per
149
+ # DER: every circuit of a DER fed by several carries its power.
150
+ kind_by_der = {device.device_id: "pv" for device in roles.pvs} | {device.device_id: "evse" for device in roles.evse}
108
151
  der_type_by_circuit = {
109
- circuit_id: kind
110
- for kind, device in (("pv", roles.pv), *(("evse", e) for e in roles.evse))
111
- if device is not None and (circuit_id := feeds.get(device.device_id))
152
+ circuit.device_id: kind
153
+ for circuit in roles.circuits
154
+ if (kind := kind_by_der.get(text(circuit, NODE_CONNECTION, PROP_FEEDS_DEVICE_ID)))
112
155
  }
113
156
 
114
157
  circuits = {}
@@ -176,12 +219,25 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re
176
219
  ("panel", panel),
177
220
  ("battery", roles.bess),
178
221
  ("mid", roles.mid),
179
- ("pv", roles.pv),
222
+ *((("pv", roles.pvs[0]),) if len(roles.pvs) == 1 else ()),
180
223
  )
181
224
  if device is not None
182
225
  ]
226
+ # One inverter keeps the singleton `pv` subject it always had. With more
227
+ # than one, each inverter is its own subject keyed like `pv_inverters`, so
228
+ # no inverter's vendor properties are filed under another's.
229
+ pv_subjects = (
230
+ [(device, ExtensionSubject(kind="pv", instance_key=pv_inverter_key(device, feeds))) for device in roles.pvs]
231
+ if len(roles.pvs) > 1
232
+ else []
233
+ )
234
+ pv_inverters = {
235
+ pv_inverter_key(device, feeds): build_pv(device, feeds, upstream, downstream, feed_statuses=feed_statuses)
236
+ for device in roles.pvs
237
+ }
238
+
183
239
  extension_properties = build_extension_properties(
184
- [*singleton_subjects, *lugs_subjects, *evse_subjects, *circuit_subjects],
240
+ [*singleton_subjects, *pv_subjects, *lugs_subjects, *evse_subjects, *circuit_subjects],
185
241
  addressed_rows(children),
186
242
  )
187
243
 
@@ -240,8 +296,14 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], re
240
296
  downstream_l1_current_a=fields.downstream_l1_current_a,
241
297
  downstream_l2_current_a=fields.downstream_l2_current_a,
242
298
  circuits=circuits,
243
- battery=build_battery(roles.bess, owners),
244
- pv=build_pv(roles.pv, feeds, upstream, downstream, feed_statuses=feed_statuses),
299
+ battery=build_battery(
300
+ roles.bess,
301
+ owners,
302
+ firmware_version=fields.firmware_version,
303
+ power_flow_battery=fields.power_flow_battery,
304
+ ),
305
+ pv=_pv_for(list(pv_inverters.values())),
306
+ pv_inverters=pv_inverters,
245
307
  mid=build_mid(roles.mid, device_names),
246
308
  # Gated on the node being declared, not on any value: every limit this
247
309
  # capability publishes is legally `0.0`, so there is no reading that can