span-panel-api-schema-1 1.1.2__tar.gz → 1.1.4__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 (42) hide show
  1. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/.gitignore +0 -5
  2. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/CHANGELOG.md +24 -0
  3. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/PKG-INFO +19 -7
  4. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/README.md +18 -6
  5. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/pyproject.toml +1 -1
  6. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/src/span_panel_api_schema_1/adapter.py +22 -6
  7. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/src/span_panel_api_schema_1/field_metadata.py +2 -2
  8. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/src/span_panel_api_schema_1/panel.py +14 -12
  9. span_panel_api_schema_1-1.1.4/src/span_panel_api_schema_1/reference/parent_child_tree.json +252 -0
  10. span_panel_api_schema_1-1.1.4/src/span_panel_api_schema_1/spec_lock.json +42 -0
  11. span_panel_api_schema_1-1.1.2/spec/fixtures/simulator_tree.json +0 -4853
  12. span_panel_api_schema_1-1.1.2/spec/fixtures/simulator_wire.json +0 -684
  13. span_panel_api_schema_1-1.1.2/src/span_panel_api_schema_1/spec_lock.json +0 -72
  14. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/spec/catalogs/breaker.json +0 -0
  15. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/spec/catalogs/charge-limit.json +0 -0
  16. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/spec/catalogs/connection.json +0 -0
  17. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/spec/catalogs/door.json +0 -0
  18. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/spec/catalogs/grid-forming.json +0 -0
  19. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/spec/catalogs/grid.json +0 -0
  20. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/spec/catalogs/info.json +0 -0
  21. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/spec/catalogs/load-shed.json +0 -0
  22. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/spec/catalogs/meter.json +0 -0
  23. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/spec/catalogs/pcs.json +0 -0
  24. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/spec/catalogs/power-flows.json +0 -0
  25. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/spec/catalogs/shed-forecast.json +0 -0
  26. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/spec/catalogs/shed.json +0 -0
  27. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/spec/catalogs/soc.json +0 -0
  28. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/spec/catalogs/status.json +0 -0
  29. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/spec/catalogs/switch.json +0 -0
  30. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/spec/registries/device-types.md +0 -0
  31. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/src/span_panel_api_schema_1/__init__.py +0 -0
  32. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/src/span_panel_api_schema_1/adoption.py +0 -0
  33. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/src/span_panel_api_schema_1/catalog.py +0 -0
  34. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/src/span_panel_api_schema_1/charge_limit.py +0 -0
  35. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/src/span_panel_api_schema_1/circuits.py +0 -0
  36. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/src/span_panel_api_schema_1/const.py +0 -0
  37. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/src/span_panel_api_schema_1/description.py +0 -0
  38. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/src/span_panel_api_schema_1/devices.py +0 -0
  39. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/src/span_panel_api_schema_1/extension.py +0 -0
  40. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/src/span_panel_api_schema_1/py.typed +0 -0
  41. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/src/span_panel_api_schema_1/snapshot.py +0 -0
  42. {span_panel_api_schema_1-1.1.2 → span_panel_api_schema_1-1.1.4}/src/span_panel_api_schema_1/transport.py +0 -0
@@ -40,8 +40,3 @@ coverage_output.log
40
40
  # of which belongs in a repository. The differential that reads them commits its
41
41
  # *verdict* only, never the capture, and skips when the file is absent.
42
42
  tests/fixtures/live_*.json
43
-
44
- # Peer checkouts. CI clones the eBus specification and SpanPanel/panelbench here so
45
- # the provenance checks have something to compare vendored bytes against; the same
46
- # layout works locally if you would rather not point .env at siblings.
47
- /peers/
@@ -9,6 +9,30 @@ 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.1.4]
13
+
14
+ 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
15
+ [#178](https://github.com/SpanPanel/span-panel-api/issues/178).
16
+
17
+ ### Fixed
18
+
19
+ - **An islanding assertion in force is now the reported islanding state even while the MID is ready.** `resolve_islanding_state` returned the MID's sensed value whenever the MID was ready and read `shed/asserted-islanding-state` only when it was not, so
20
+ `dsm_state` could disagree with the state the panel was acting on. `ON_GRID` or `OFF_GRID` asserted now wins; otherwise the ready MID's value, then the existing fallback.
21
+ - **`set_dominant_power_source_target` returns None unless the panel declares `shed/asserted-islanding-state` settable**, the same refusal `set_circuit_relay_target` makes.
22
+ - **`dominant_power_source_payload` refuses `NONE` and `UNKNOWN`** instead of publishing `NONE`, which the panel ignores. An assertion clears itself once the panel's link to the battery recovers.
23
+
24
+ ## [1.1.3]
25
+
26
+ `spec_lock.json` ships inside this wheel, so its shape changing is a release here even though the parser did not change.
27
+
28
+ ### Added
29
+
30
+ - **`span_panel_api_schema_1/reference/parent_child_tree.json`** — the reference capture, shipped as package data so a downstream test suite replays the bytes its pinned version was tested against instead of vendoring a copy.
31
+
32
+ ### Changed
33
+
34
+ - **`spec_lock.json` loses its `peers` block** — the producer this parser is developed against is now an ordinary pinned dev dependency of the repository rather than a peer recorded in the lockfile.
35
+
12
36
  ## [1.1.2]
13
37
 
14
38
  A refreshed peer pin: `spec_lock.json` ships inside this wheel, so the reference capture's producer moving is a release here even though the parser did not change.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api-schema-1
3
- Version: 1.1.2
3
+ Version: 1.1.4
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
@@ -54,16 +54,28 @@ span-panel-api's transport covers the whole tree and each message is routed to w
54
54
 
55
55
  ## Conformance
56
56
 
57
- `spec_lock.json` ships with the package and records what this parser targets: the firmware range, the eBus specification commit its vocabulary was read from, and the version of every capability, device and registry it implements. The capability catalogs it
58
- addresses are byte-copied under `spec/`.
57
+ `spec_lock.json` ships with the package and is this parser's declaration as a consumer: the firmware range it reads, the eBus specification commit its vocabulary was read from, and the version of every capability, device and registry it implements. The
58
+ capability catalogs it addresses are byte-copied into the repository under `spec/`, and the repository checks them against the copies the eBus emitter carries in its own wheel.
59
59
 
60
60
  Those copies exist to be **checked against, never parsed in production** — units and datatypes come from each device's `$description`, since a catalog is the superset across all hardware rather than a statement about the panel in front of you. The suite
61
61
  asks the consumer's question rather than the publisher's: is every name this adapter _reads_ one the specification defines? A consumer addressing a name that no longer exists does not fail loudly, it goes quiet — the property never arrives, metadata lookup
62
62
  returns `None`, and an entity disappears.
63
63
 
64
- ## Reference payloads
64
+ ## Reference payload
65
65
 
66
- A retained-topic capture of a full 40-space parent/child panel, and the replay that turns it back into devices, are fixtures of the repository's test suite at `tests/reference_payloads/`.
66
+ `span_panel_api_schema_1/reference/parent_child_tree.json` ships in this wheel: a retained-topic capture of a full 40-space parent/child panel — 14 devices, `{device_id: {topic: payload}}`, every value a string exactly as a broker retains it.
67
67
 
68
- **`span_panel_api_schema_1.reference_payloads` no longer exists.** It was package data until 1.1.0 — carried in this wheel because it sat inside the package directory, though no runtime path read it. A consumer that was importing it should vendor the bytes
69
- it needs and record the release it took them from, asserting that against `importlib.metadata.version("span-panel-api-schema-1")` so a moved pin that outruns the copy fails loudly instead of testing against a tree no panel publishes.
68
+ **Test-support data, and no runtime path reads it.** It ships so a downstream test suite pinned to a version of this adapter replays the bytes that version was built and tested against, out of its own site-packages:
69
+
70
+ ```python
71
+ from importlib.resources import files
72
+ import json
73
+
74
+ tree = json.loads((files("span_panel_api_schema_1") / "reference" / "parent_child_tree.json").read_text(encoding="utf-8"))
75
+ ```
76
+
77
+ Vendoring a copy instead means also maintaining a guard to keep the copy honest, which is what this replaces. It was package data until 1.1.0, a fixture of the repository's test suite for 1.1.2, and package data again from 1.1.3 — for the cost, not for the
78
+ principle: no runtime path has ever read it.
79
+
80
+ The bytes are produced by `ebus-panel-sim`, pinned in the repository's dev dependencies, and the repository's suite regenerates the capture in-process on every run and compares it, so a tree the pinned producer does not reproduce is a test failure rather
81
+ than a claim in a document.
@@ -41,16 +41,28 @@ span-panel-api's transport covers the whole tree and each message is routed to w
41
41
 
42
42
  ## Conformance
43
43
 
44
- `spec_lock.json` ships with the package and records what this parser targets: the firmware range, the eBus specification commit its vocabulary was read from, and the version of every capability, device and registry it implements. The capability catalogs it
45
- addresses are byte-copied under `spec/`.
44
+ `spec_lock.json` ships with the package and is this parser's declaration as a consumer: the firmware range it reads, the eBus specification commit its vocabulary was read from, and the version of every capability, device and registry it implements. The
45
+ capability catalogs it addresses are byte-copied into the repository under `spec/`, and the repository checks them against the copies the eBus emitter carries in its own wheel.
46
46
 
47
47
  Those copies exist to be **checked against, never parsed in production** — units and datatypes come from each device's `$description`, since a catalog is the superset across all hardware rather than a statement about the panel in front of you. The suite
48
48
  asks the consumer's question rather than the publisher's: is every name this adapter _reads_ one the specification defines? A consumer addressing a name that no longer exists does not fail loudly, it goes quiet — the property never arrives, metadata lookup
49
49
  returns `None`, and an entity disappears.
50
50
 
51
- ## Reference payloads
51
+ ## Reference payload
52
52
 
53
- A retained-topic capture of a full 40-space parent/child panel, and the replay that turns it back into devices, are fixtures of the repository's test suite at `tests/reference_payloads/`.
53
+ `span_panel_api_schema_1/reference/parent_child_tree.json` ships in this wheel: a retained-topic capture of a full 40-space parent/child panel — 14 devices, `{device_id: {topic: payload}}`, every value a string exactly as a broker retains it.
54
54
 
55
- **`span_panel_api_schema_1.reference_payloads` no longer exists.** It was package data until 1.1.0 — carried in this wheel because it sat inside the package directory, though no runtime path read it. A consumer that was importing it should vendor the bytes
56
- it needs and record the release it took them from, asserting that against `importlib.metadata.version("span-panel-api-schema-1")` so a moved pin that outruns the copy fails loudly instead of testing against a tree no panel publishes.
55
+ **Test-support data, and no runtime path reads it.** It ships so a downstream test suite pinned to a version of this adapter replays the bytes that version was built and tested against, out of its own site-packages:
56
+
57
+ ```python
58
+ from importlib.resources import files
59
+ import json
60
+
61
+ tree = json.loads((files("span_panel_api_schema_1") / "reference" / "parent_child_tree.json").read_text(encoding="utf-8"))
62
+ ```
63
+
64
+ Vendoring a copy instead means also maintaining a guard to keep the copy honest, which is what this replaces. It was package data until 1.1.0, a fixture of the repository's test suite for 1.1.2, and package data again from 1.1.3 — for the cost, not for the
65
+ principle: no runtime path has ever read it.
66
+
67
+ The bytes are produced by `ebus-panel-sim`, pinned in the repository's dev dependencies, and the repository's suite regenerates the capture in-process on every run and compares it, so a tree the pinned producer does not reproduce is a test failure rather
68
+ than a claim in a document.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api-schema-1"
3
- version = "1.1.2"
3
+ version = "1.1.4"
4
4
  description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -40,7 +40,7 @@ from span_panel_api_schema_1.const import (
40
40
  STATE_READY,
41
41
  TYPE_CIRCUIT,
42
42
  )
43
- from span_panel_api_schema_1.description import device_type
43
+ from span_panel_api_schema_1.description import declared_settable, device_type, node_properties
44
44
  from span_panel_api_schema_1.field_metadata import build_field_metadata
45
45
  from span_panel_api_schema_1.panel import integer
46
46
  from span_panel_api_schema_1.snapshot import TreeRoles, build_snapshot, harmonised_evse_keys
@@ -275,9 +275,23 @@ class SchemaOneAdapter:
275
275
  the BESS stops discharging. Returning None here, as this did until the
276
276
  successor was decided, left that recovery unavailable during an outage.
277
277
 
278
+ **The panel decides whether a write takes effect, by its own link to the
279
+ battery.** It accepts `ON_GRID` or `OFF_GRID` only while the battery's
280
+ `status/communication-state` is not `OK`; otherwise it ignores the write
281
+ and the published value does not change. A lost MID alone does not make
282
+ the write eligible. `NONE` is always ignored.
283
+
284
+ **None unless the panel declares the assertion `$settable`**, the same
285
+ refusal `set_circuit_relay_target` makes. Absence of the declaration,
286
+ or of the property, authorizes nothing (see
287
+ `description.declared_settable`).
288
+
278
289
  Payload translation is not optional: the flat enum this protocol speaks
279
290
  is not the one the panel accepts. See `dominant_power_source_payload`.
280
291
  """
292
+ root = self._controller.get_root(self._serial_number)
293
+ if not declared_settable(node_properties(root, NODE_SHED).get(PROP_ASSERTED_ISLANDING_STATE)):
294
+ return None
281
295
  return self._target(self._serial_number, NODE_SHED, PROP_ASSERTED_ISLANDING_STATE)
282
296
 
283
297
  def dominant_power_source_payload(self, value: str) -> str | None:
@@ -291,17 +305,19 @@ class SchemaOneAdapter:
291
305
 
292
306
  The narrowing loses nothing, because the six values were a *source
293
307
  class* pressed into service as a manual override and the job only ever
294
- needed on-grid, off-grid, or no assertion. Anything not recognised
295
- returns None rather than guessing, so the transport refuses the command
296
- instead of asserting something the user did not ask for.
308
+ needed on-grid or off-grid. Anything not recognized returns None rather
309
+ than guessing, so the transport refuses the command instead of
310
+ asserting something the user did not ask for.
311
+
312
+ **`NONE` and `UNKNOWN` are refused too.** The panel ignores a written
313
+ `NONE`, so publishing one would report a clear that never happened. An
314
+ assertion clears itself once the panel's link to the battery recovers.
297
315
  """
298
316
  return {
299
317
  "GRID": "ON_GRID",
300
318
  "BATTERY": "OFF_GRID",
301
319
  "PV": "OFF_GRID",
302
320
  "GENERATOR": "OFF_GRID",
303
- "NONE": "NONE",
304
- "UNKNOWN": "NONE",
305
321
  }.get(value.strip().upper())
306
322
 
307
323
  def set_evse_charge_limit_target(self, node_id: str) -> ControlTarget | None:
@@ -472,8 +472,8 @@ _CONSUMED_OFF_SNAPSHOT: dict[tuple[str, str, str], str] = {
472
472
  "parses the tree, so it is consumed before any snapshot exists"
473
473
  ),
474
474
  (TYPE_PANEL, NODE_SHED, "asserted-islanding-state"): (
475
- "tier 2 of resolve_islanding_state (panel.py), shadowed wherever a MID answers "
476
- "at tier 1, and the write target of set_dominant_power_source_target"
475
+ "tier 1 of resolve_islanding_state (panel.py), which returns a non-NONE "
476
+ "assertion first, and the write target of set_dominant_power_source_target"
477
477
  ),
478
478
  (TYPE_LUGS, NODE_CONNECTION, "feeds-device-id"): (
479
479
  "the downstream-lugs feedthrough branch of resolve_relative_position "
@@ -600,7 +600,6 @@ _GRID_POWER_EPSILON_W = 1.0
600
600
 
601
601
  ISLANDING_ON_GRID = "ON_GRID"
602
602
  ISLANDING_OFF_GRID = "OFF_GRID"
603
- ASSERTION_NONE = "NONE"
604
603
 
605
604
 
606
605
  def resolve_islanding_state(mid: DiscoveredDevice | None, panel: DiscoveredDevice) -> str | None:
@@ -608,17 +607,19 @@ def resolve_islanding_state(mid: DiscoveredDevice | None, panel: DiscoveredDevic
608
607
 
609
608
  | tier | condition | source |
610
609
  | --- | --- | --- |
611
- | 1 | MID `$state` is `ready` and `islanding-state` present | sensed |
612
- | 2 | MID not `ready` | `shed/asserted-islanding-state`, when not `NONE` |
610
+ | 1 | `shed/asserted-islanding-state` is `ON_GRID` or `OFF_GRID` | asserted |
611
+ | 2 | MID `$state` is `ready` and `islanding-state` present | sensed |
613
612
  | 3 | no MID at all | `power-flows/grid` heuristic |
614
613
  | 4 | none of the above | unknown |
615
614
 
616
- **Tier 2 is the reason the assertion control exists.** When comms to the BESS or MID
617
- are lost and the grid returns, the user asserts the grid is up so the BESS stops
618
- discharging. Declining to read it here would wire the control and then ignore it at
619
- exactly the moment it matters. Nothing is hidden by doing so: the MID is a device, so
620
- it goes *unavailable* in Home Assistant when it stops publishing, and the assertion is
621
- itself visible as the control the user set.
615
+ **An assertion in force is the effective state, so it outranks the MID.** When the
616
+ panel's link to the battery is not healthy (the battery's `status/communication-state`
617
+ is not `OK`) and the grid returns, the user asserts the grid is up so the BESS stops
618
+ discharging, and the panel acts on that assertion until it clears. The panel ignores
619
+ the write while that link is healthy.
620
+ Reading the MID first would report the sensed value while the panel is acting on a
621
+ different one. The assertion clears itself back to `NONE` once the battery link
622
+ recovers, so the MID's answer returns without anything here having to expire it.
622
623
 
623
624
  **Tier 3 never answers `OFF_GRID`, and never asserts on-grid from a missing MID.** An
624
625
  earlier draft reasoned that no MID means no islanding authority means on-grid. That is
@@ -627,14 +628,15 @@ def resolve_islanding_state(mid: DiscoveredDevice | None, panel: DiscoveredDevic
627
628
  counterexample. Grid power flowing is positive evidence of being on-grid; its absence
628
629
  is not evidence of the opposite.
629
630
  """
631
+ asserted = text(panel, NODE_SHED, PROP_ASSERTED_ISLANDING_STATE)
632
+ if asserted in (ISLANDING_ON_GRID, ISLANDING_OFF_GRID):
633
+ return asserted
634
+
630
635
  if mid is not None:
631
636
  if mid.state == "ready":
632
637
  sensed = text(mid, NODE_GRID, PROP_ISLANDING_STATE)
633
638
  if sensed:
634
639
  return sensed
635
- asserted = text(panel, NODE_SHED, PROP_ASSERTED_ISLANDING_STATE)
636
- if asserted and asserted != ASSERTION_NONE:
637
- return asserted
638
640
  return None
639
641
 
640
642
  grid_power = number(panel, NODE_POWER_FLOWS, "grid")