span-panel-api 3.0.0b2__tar.gz → 3.0.0b3__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 (151) hide show
  1. span_panel_api-3.0.0b3/.env.example +54 -0
  2. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/.gitignore +6 -0
  3. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/.markdownlint-cli2.jsonc +6 -0
  4. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/.pre-commit-config.yaml +12 -7
  5. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/CHANGELOG.md +37 -0
  6. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/PKG-INFO +2 -2
  7. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/RELEASE.md +4 -4
  8. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-0/CHANGELOG.md +16 -0
  9. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-0/pyproject.toml +1 -1
  10. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-0/src/span_panel_api_schema_0/adapter.py +17 -0
  11. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-0/src/span_panel_api_schema_0/consumer.py +8 -4
  12. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py +8 -4
  13. span_panel_api-3.0.0b3/packages/schema-1/CHANGELOG.md +103 -0
  14. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-1/pyproject.toml +8 -3
  15. span_panel_api-3.0.0b3/packages/schema-1/spec/catalogs/breaker.json +52 -0
  16. span_panel_api-3.0.0b3/packages/schema-1/spec/catalogs/connection.json +72 -0
  17. span_panel_api-3.0.0b3/packages/schema-1/spec/catalogs/door.json +17 -0
  18. span_panel_api-3.0.0b3/packages/schema-1/spec/catalogs/grid-forming.json +21 -0
  19. span_panel_api-3.0.0b3/packages/schema-1/spec/catalogs/grid.json +38 -0
  20. span_panel_api-3.0.0b3/packages/schema-1/spec/catalogs/info.json +52 -0
  21. span_panel_api-3.0.0b3/packages/schema-1/spec/catalogs/load-shed.json +18 -0
  22. span_panel_api-3.0.0b3/packages/schema-1/spec/catalogs/meter.json +201 -0
  23. span_panel_api-3.0.0b3/packages/schema-1/spec/catalogs/pcs.json +111 -0
  24. span_panel_api-3.0.0b3/packages/schema-1/spec/catalogs/power-flows.json +35 -0
  25. span_panel_api-3.0.0b3/packages/schema-1/spec/catalogs/shed.json +24 -0
  26. span_panel_api-3.0.0b3/packages/schema-1/spec/catalogs/soc.json +35 -0
  27. span_panel_api-3.0.0b3/packages/schema-1/spec/catalogs/status.json +28 -0
  28. span_panel_api-3.0.0b3/packages/schema-1/spec/catalogs/switch.json +29 -0
  29. span_panel_api-3.0.0b3/packages/schema-1/spec/fixtures/simulator_tree.json +4984 -0
  30. span_panel_api-3.0.0b3/packages/schema-1/spec/fixtures/simulator_wire.json +679 -0
  31. span_panel_api-3.0.0b3/packages/schema-1/spec/registries/device-types.md +56 -0
  32. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-1/src/span_panel_api_schema_1/adapter.py +61 -10
  33. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-1/src/span_panel_api_schema_1/const.py +12 -0
  34. span_panel_api-3.0.0b3/packages/schema-1/src/span_panel_api_schema_1/devices.py +245 -0
  35. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +56 -3
  36. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-1/src/span_panel_api_schema_1/panel.py +223 -5
  37. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +77 -13
  38. span_panel_api-3.0.0b3/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +53 -0
  39. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/pyproject.toml +11 -2
  40. span_panel_api-3.0.0b3/scripts/capture_flat_reference.py +130 -0
  41. span_panel_api-3.0.0b3/scripts/capture_live_flat.py +147 -0
  42. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/scripts/verify_adapterless_install.py +4 -5
  43. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/__init__.py +2 -0
  44. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/adapters.py +98 -67
  45. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/factory.py +5 -1
  46. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/models.py +70 -6
  47. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/mqtt/client.py +382 -13
  48. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/protocol.py +12 -0
  49. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/conftest.py +30 -0
  50. span_panel_api-3.0.0b3/tests/fixtures/flat_wire.json +563 -0
  51. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_adapters_discovery.py +62 -14
  52. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_auth_and_homie_helpers.py +0 -1
  53. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_detection_auth.py +0 -1
  54. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_factory_dispatch.py +1 -1
  55. span_panel_api-3.0.0b3/tests/test_live_flat_differential.py +166 -0
  56. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_mqtt_connect_flow.py +65 -0
  57. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_mqtt_homie.py +7 -7
  58. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_protocol_conformance.py +1 -0
  59. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_protocol_models.py +0 -1
  60. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_public_api_unchanged.py +3 -0
  61. span_panel_api-3.0.0b3/tests/test_redispatch_on_reconnect.py +301 -0
  62. span_panel_api-3.0.0b3/tests/test_schema_generation_cross_check.py +119 -0
  63. span_panel_api-3.0.0b3/tests/test_schema_migration_delta.py +475 -0
  64. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_schema_one_adapter.py +165 -4
  65. span_panel_api-3.0.0b3/tests/test_schema_one_against_simulator.py +257 -0
  66. span_panel_api-3.0.0b3/tests/test_schema_one_conformance.py +516 -0
  67. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_schema_one_devices.py +55 -10
  68. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_schema_one_panel.py +175 -1
  69. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_schema_one_snapshot.py +26 -10
  70. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/uv.lock +14 -14
  71. span_panel_api-3.0.0b2/packages/schema-1/CHANGELOG.md +0 -60
  72. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/devices.py +0 -143
  73. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/.codefactor +0 -0
  74. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/.codefactor.yml +0 -0
  75. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/.deps-installed +0 -0
  76. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  77. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  78. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/.github/dependabot.yml +0 -0
  79. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/.github/workflows/ci.yml +0 -0
  80. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/.github/workflows/dependabot-auto-approve.yml +0 -0
  81. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/.github/workflows/dependabot-auto-merge.yml +0 -0
  82. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/.github/workflows/release.yml +0 -0
  83. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/.markdownlint.json +0 -0
  84. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/.prettierrc.json +0 -0
  85. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/.vscode/extensions.json +0 -0
  86. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/.vscode/tasks.json +0 -0
  87. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/DEVELOPMENT.md +0 -0
  88. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/LICENSE +0 -0
  89. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/README.md +0 -0
  90. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/SECURITY.md +0 -0
  91. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/conftest.py +0 -0
  92. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/developer_attribute_readme.md +0 -0
  93. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/openapi.json +0 -0
  94. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-0/README.md +0 -0
  95. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-0/src/span_panel_api_schema_0/__init__.py +0 -0
  96. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-0/src/span_panel_api_schema_0/accumulator.py +0 -0
  97. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-0/src/span_panel_api_schema_0/const.py +0 -0
  98. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-0/src/span_panel_api_schema_0/py.typed +0 -0
  99. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-1/README.md +0 -0
  100. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-1/src/span_panel_api_schema_1/__init__.py +0 -0
  101. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-1/src/span_panel_api_schema_1/circuits.py +0 -0
  102. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-1/src/span_panel_api_schema_1/py.typed +0 -0
  103. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/packages/schema-1/src/span_panel_api_schema_1/transport.py +0 -0
  104. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/pytest.ini +0 -0
  105. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/pytest_output.log +0 -0
  106. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/scripts/__init__.py +0 -0
  107. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/scripts/coverage.py +0 -0
  108. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/scripts/format.sh +0 -0
  109. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/scripts/format_markdown.py +0 -0
  110. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/scripts/test_live_auth.py +0 -0
  111. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/scripts/verify_reconnect.py +0 -0
  112. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/setup-hooks.sh +0 -0
  113. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/_http.py +0 -0
  114. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/auth.py +0 -0
  115. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/const.py +0 -0
  116. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/detection.py +0 -0
  117. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/dispatch.py +0 -0
  118. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/exceptions.py +0 -0
  119. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/mqtt/__init__.py +0 -0
  120. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/mqtt/async_client.py +0 -0
  121. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/mqtt/connection.py +0 -0
  122. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/mqtt/const.py +0 -0
  123. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/mqtt/models.py +0 -0
  124. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/phase_validation.py +0 -0
  125. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/py.typed +0 -0
  126. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/src/span_panel_api/schema_drift.py +0 -0
  127. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  128. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  129. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  130. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/fixtures/parent_child_tree.json +0 -0
  131. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/fixtures/v2/README.md +0 -0
  132. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/fixtures/v2/homie_schema.json +0 -0
  133. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/fixtures/v2/status.json +0 -0
  134. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/simulation_fixtures/circuits.response.txt +0 -0
  135. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/simulation_fixtures/panel.response.txt +0 -0
  136. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/simulation_fixtures/soe.response.txt +0 -0
  137. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/simulation_fixtures/status.response.txt +0 -0
  138. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_accumulator.py +0 -0
  139. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_async_mqtt_client.py +0 -0
  140. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_exceptions.py +0 -0
  141. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_field_metadata.py +0 -0
  142. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_mqtt_bridge.py +0 -0
  143. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_mqtt_client_connection.py +0 -0
  144. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_mqtt_debounce.py +0 -0
  145. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_packaging.py +0 -0
  146. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_phase_validation_configs.py +0 -0
  147. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_phase_validation_errors.py +0 -0
  148. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_schema_one_circuits.py +0 -0
  149. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_schema_one_transport.py +0 -0
  150. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_schema_provenance.py +0 -0
  151. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b3}/tests/test_schema_zero_adapter.py +0 -0
@@ -0,0 +1,54 @@
1
+ # Local-developer environment variables for span-panel-api.
2
+ #
3
+ # Copy to `.env` and fill in. `.env` is gitignored and must stay that way.
4
+ #
5
+ # `tests/conftest.py` reads this file directly, so no direnv or dotenv package is
6
+ # needed. A value already exported in your shell wins over anything here — the
7
+ # file supplies defaults, it does not override an intentional choice.
8
+ #
9
+ # Everything below is optional. Without it the suite runs in full and the checks
10
+ # that need a sibling checkout skip themselves rather than fail, which is what CI
11
+ # does. They are the *provenance* half of the schema_1 conformance suite: they
12
+ # verify that the vendored copies still match their sources. The conformance and
13
+ # coverage checks, which are the ones that catch real defects, run regardless.
14
+
15
+ # A checkout of the eBus specification.
16
+ #
17
+ # git clone https://github.com/electrification-bus/specification
18
+ #
19
+ # Enables the byte comparison of `packages/schema-1/spec/catalogs/*.json` against
20
+ # the specification's `capabilities/`. Position the checkout at the commit
21
+ # `spec_lock.json` pins (`synced_commit`) before believing a failure — a checkout
22
+ # on a newer HEAD reports differences that are drift, not corruption.
23
+ #EBUS_SPEC_DIR=/path/to/specification
24
+
25
+ # A checkout of SpanPanel/panelbench, the publisher this parser is developed
26
+ # against.
27
+ #
28
+ # git clone git@github.com:SpanPanel/panelbench.git
29
+ #
30
+ # Enables verifying the two vendored captures and the recorded peer pins against
31
+ # the producer itself. The tree capture is compared byte for byte; the wire
32
+ # capture is compared on shape, because its values are perturbed by the
33
+ # simulator's `noise_factor` and an advancing clock.
34
+ #PANELBENCH_DIR=/path/to/panelbench
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # A live SPAN panel running flat firmware (optional, and nothing needs it)
38
+ # ---------------------------------------------------------------------------
39
+ #
40
+ # Enables `scripts/capture_live_flat.py`, which takes a retained capture from a
41
+ # real panel so the frozen flat simulator can be measured against firmware rather
42
+ # than trusted. Without it, `test_live_flat_differential.py` skips.
43
+ #
44
+ # The username IS the panel serial, so treat both of these as secrets and keep
45
+ # them here. The capture the script writes is gitignored for the same reason: it
46
+ # carries the serial, the household's circuit names and real consumption. Only the
47
+ # differential's verdict is ever committed.
48
+ #
49
+ # TLS is on and certificate validation is off: the panel presents a self-signed
50
+ # certificate.
51
+ #LIVE_PANEL_HOST=192.168.1.50
52
+ #LIVE_PANEL_PORT=8883
53
+ #LIVE_PANEL_USERNAME=your-panel-serial
54
+ #LIVE_PANEL_PASSWORD=
@@ -34,3 +34,9 @@ dmypy.json
34
34
  coverage_output.log
35
35
  **/.DS_Store
36
36
  .local_coverage_data
37
+
38
+ # Captures taken from a real panel. These carry the panel's serial (which is also
39
+ # its MQTT username), the household's circuit names, and real consumption — none
40
+ # of which belongs in a repository. The differential that reads them commits its
41
+ # *verdict* only, never the capture, and skips when the file is absent.
42
+ tests/fixtures/live_*.json
@@ -36,6 +36,12 @@
36
36
  },
37
37
  "globs": ["**/*.md"],
38
38
  "ignores": [
39
+ // Byte copies of the eBus specification, verified by byte comparison in
40
+ // tests/test_schema_one_conformance.py. Upstream's line lengths are not
41
+ // ours to correct, and a fix here would invalidate that comparison.
42
+ // `globs` above scans the tree directly, so pre-commit's `exclude` cannot
43
+ // filter this out -- it has to be ignored here.
44
+ "packages/schema-1/spec/**",
39
45
  ".venv/**",
40
46
  "venv/**",
41
47
  "node_modules/**",
@@ -3,10 +3,15 @@ repos:
3
3
  - repo: https://github.com/pre-commit/pre-commit-hooks
4
4
  rev: v5.0.0
5
5
  hooks:
6
+ # `packages/schema-1/spec/` holds byte copies of the eBus specification,
7
+ # verified by byte comparison in tests/test_schema_one_conformance.py. Any
8
+ # hook that rewrites a file must skip it: a "fix" there would silently
9
+ # invalidate the comparison that makes the copies trustworthy. Non-mutating
10
+ # checks (check-json) deliberately still run, since a corrupt copy should fail.
6
11
  - id: trailing-whitespace
7
- exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*'
12
+ exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*'
8
13
  - id: end-of-file-fixer
9
- exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*'
14
+ exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*'
10
15
  - id: check-yaml
11
16
  exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*'
12
17
  - id: check-toml
@@ -20,7 +25,7 @@ repos:
20
25
  exclude: '^src/span_panel_api/generated_client/.*|generate_client\.py|scripts/.*|tests/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|^examples/.*'
21
26
  - id: mixed-line-ending
22
27
  args: ['--fix=lf']
23
- exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*'
28
+ exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*'
24
29
 
25
30
  # Ruff for formatting and linting
26
31
  - repo: https://github.com/astral-sh/ruff-pre-commit
@@ -55,7 +60,7 @@ repos:
55
60
  - id: prettier
56
61
  types: [markdown]
57
62
  args: ['--config', '.prettierrc.json']
58
- exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|node_modules/.*|htmlcov/.*'
63
+ exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|node_modules/.*|htmlcov/.*'
59
64
 
60
65
  # Markdownlint for markdown files (after Prettier formatting)
61
66
  - repo: https://github.com/DavidAnson/markdownlint-cli2
@@ -63,7 +68,7 @@ repos:
63
68
  hooks:
64
69
  - id: markdownlint-cli2
65
70
  args: ['--config', '.markdownlint-cli2.jsonc']
66
- exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|node_modules/.*|htmlcov/.*'
71
+ exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|node_modules/.*|htmlcov/.*'
67
72
 
68
73
  # MyPy for type checking
69
74
  - repo: https://github.com/pre-commit/mirrors-mypy
@@ -80,7 +85,7 @@ repos:
80
85
  # schema-1 parses the parent/child tree with the eBus SDK, which
81
86
  # ships py.typed — so the hook needs it installed to resolve those
82
87
  # types rather than silently reporting import-not-found.
83
- - ebus-sdk>=0.18.0
88
+ - ebus-sdk>=0.19.0
84
89
  args: ['--config-file=pyproject.toml']
85
90
  exclude: '^src/span_panel_api/generated_client/.*|scripts/.*|tests/.*|docs/.*|examples/.*|\..*_cache/.*|dist/.*|venv/.*'
86
91
 
@@ -98,7 +103,7 @@ repos:
98
103
  - paho-mqtt
99
104
  # schema-1 imports the eBus SDK; without it here the hook reports
100
105
  # import-error for a dependency that is correctly declared.
101
- - ebus-sdk>=0.18.0
106
+ - ebus-sdk>=0.19.0
102
107
  exclude: '^src/span_panel_api/generated_client/.*|tests/.*|generate_client\.py|scripts/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|^examples/.*'
103
108
 
104
109
  # Check for common security issues
@@ -4,6 +4,43 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [3.0.0b3] - 08/2026
8
+
9
+ Pre-release. Normalises DER identity onto v1.0's vocabulary, and stops deriving the grid answers that v1.0 states outright.
10
+
11
+ ### Changed
12
+
13
+ - **BREAKING — DER identity speaks v1.0's vocabulary on every device class.** `model` is the human designation and `part_number` the SKU, on `battery`, `evse` and `pv` alike. `product_name` is retired on all three. Flat is the inconsistent side, not v1.0:
14
+ it puts the SKU in `bess/model` and in `evse/part-number`, the same concept under two names, and gives PV neither. `schema_1` used to cross over (`info/part-number` → `battery.model`) to hold each entity's displayed meaning still, which worked and
15
+ permanently encoded flat's irregularity in the snapshot. `schema_0` now translates flat into the normalised shape instead of mirroring it. Measured: every EVSE identity field reads identically on both adapters, so for that device class identity stops
16
+ being a migration delta at all. **`battery.model` changes value for existing flat users at this upgrade** — it gains the designation where it carried the SKU. That is the deliberate trade: a change we schedule in a library release beats the same change
17
+ arriving unplanned during a firmware upgrade a user did not choose the timing of.
18
+ - **Consumers reading `product_name` must move to `model` in the same release.** The Home Assistant integration builds its device-registry model from it; left unchanged, device cards go blank.
19
+
20
+ ### Added
21
+
22
+ - **`SpanMidSnapshot`, and `SpanPanelSnapshot.mid`.** v1.0 publishes a Microgrid Interconnect Device and the enclosure model puts the `grid` capability on it rather than on the enclosure, so islanding state, grid state and the grid-forming entity live
23
+ there. Previously one of its five properties was read and the device discarded. Purely additive: no flat panel publishes a MID, so nothing existing changes. Presence is `snapshot.mid is not None` rather than a sentinel field, and identity is
24
+ `info/serial-number` rather than the Homie device id, which the proxy model warns is not stable across a proxy-to-native transition.
25
+
26
+ ### Fixed
27
+
28
+ - **Adapter discovery no longer blocks the caller's event loop, and no longer imports adapters the panel will never use.** Two defects with one cause: discovery resolved the whole entry-point group up front, on the calling thread. A flat panel therefore
29
+ imported `schema_1` — and with it the eBus SDK and jsonschema — on every connection, for a parser it would not call. Home Assistant reported the whole sequence (`listdir`, `read_text`, `open`, `scandir`) as blocking calls inside the event loop and asked
30
+ for a bug report, with setup stalled 2.0s on a cold import cache. Enumeration and resolution are now separate: `installed_adapter_keys()` reads distribution metadata only, and an adapter is imported the first time a panel asks for that key. The async
31
+ paths run both in a thread. Resolution stays cached per key, which is what keeps the synchronous pre-rebuild callback free of I/O. **`discover_adapters()` is replaced by `installed_adapter_keys()`**, which returns registered names rather than a registry
32
+ of loaded classes — verifying every name would mean importing every package, which is the cost being removed. `SpanMqttClient.available_adapters` becomes `installed_adapters` for the same reason.
33
+ - **A firmware upgrade to a schema generation this install cannot parse is reported instead of raised into a background task.** The redispatch path resolves the new adapter before touching any state, so a flat-only install that meets a v1.0 panel logs
34
+ which package is missing and keeps the parser it has. Previously `SpanPanelAdapterMissingError` escaped a fire-and-forget task as a bare traceback.
35
+ - **`dsm_state` and `current_run_config` are read from the MID instead of reading `UNKNOWN`.** Both are existing entities that had degraded on v1.0 — not because a source vanished, but because `schema_0` _derives_ them and the derivation was never ported.
36
+ v1.0 states the answer, so the multi-signal heuristic is gone: sensed from a ready MID, falling back to the user's `shed/asserted-islanding-state` when it is not ready, then to a `power-flows/grid` heuristic when there is no MID at all, and unknown
37
+ otherwise. A missing MID never reports on-grid — it means SPAN is not the islanding authority, not that the site is on grid, and a generator-fed island is the counterexample. `PANEL_BACKUP` versus `PANEL_OFF_GRID` becomes authoritative rather than
38
+ guessed, because v1.0 names the forming device and its class is recoverable from the tree.
39
+ - **`grid_islandable` is mapped to `grid-forming/capable`** over the BESS's inverter children, as the disjunction — a panel does not island, its DER does, and flat expressed a property of the DER as a property of the enclosure. It returns `None` rather
40
+ than `False` when nothing publishes it, so absence stays a gap instead of becoming a claim. No producer publishes it today, which is recorded rather than worked around.
41
+ - **EVSE identity survives the migration.** The snapshot key and `node_id` — which a consumer builds a `unique_id` and a device-registry identifier from — were the v1.0 device id on `schema_1` and firmware's node name on `schema_0`, so every charger would
42
+ have orphaned and reappeared as a duplicate. Both are the Drive's serial now, which is what real flat firmware keys by.
43
+
7
44
  ## [3.0.0b2] - 08/2026
8
45
 
9
46
  Pre-release. Releases the reshaped `SchemaAdapter` protocol that `3.0.0b1` predates, and makes the mismatch between the two detectable rather than fatal at construction.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: span-panel-api
3
- Version: 3.0.0b2
3
+ Version: 3.0.0b3
4
4
  Summary: A client library 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
@@ -162,9 +162,9 @@ CI going green proves the build, not the install. The seam this repository is bu
162
162
  # 1. The bootstrap alone must fail by name, not with ModuleNotFoundError
163
163
  python3 -m venv .solo && ./.solo/bin/pip install --pre span-panel-api
164
164
  ./.solo/bin/python -c "
165
- from span_panel_api.adapters import discover_adapters, resolve_adapter, DEFAULT_ADAPTER_KEY
165
+ from span_panel_api.adapters import installed_adapter_keys, resolve_adapter, DEFAULT_ADAPTER_KEY
166
166
  from span_panel_api.exceptions import SpanPanelAdapterMissingError
167
- print('adapters:', sorted(discover_adapters()))
167
+ print('adapters:', installed_adapter_keys())
168
168
  try:
169
169
  resolve_adapter(DEFAULT_ADAPTER_KEY, 'release check')
170
170
  except SpanPanelAdapterMissingError as exc:
@@ -174,8 +174,8 @@ except SpanPanelAdapterMissingError as exc:
174
174
  # 2. Both packages: the adapter resolves through discovery
175
175
  python3 -m venv .both && ./.both/bin/pip install --pre span-panel-api span-panel-api-schema-0
176
176
  ./.both/bin/python -c "
177
- from span_panel_api.adapters import discover_adapters
178
- print('adapters:', sorted(discover_adapters()))
177
+ from span_panel_api.adapters import installed_adapter_keys
178
+ print('adapters:', installed_adapter_keys())
179
179
  "
180
180
  ```
181
181
 
@@ -7,6 +7,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
7
7
  Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is fixed — the flat single-device schema, SPAN firmware `r202603` through `r202627` — and is identified by `SUPPORTS_DATA_MODEL_VERSIONS`
8
8
  rather than by this version number. A release here means this parser changed, never that the panel did.
9
9
 
10
+ ## [1.0.0b3] - 08/2026
11
+
12
+ Pre-release. Requires `span-panel-api` 3.0.0b2 or newer — unchanged, because nothing added here reaches for anything newer.
13
+
14
+ ### Changed
15
+
16
+ - **BREAKING — DER identity is translated into v1.0's vocabulary rather than mirroring flat's names.** `model` is the human designation and `part_number` the SKU, on `battery`, `evse` and `pv` alike; `product_name` is retired on all three. Flat is the
17
+ irregular side: it puts the SKU in `bess/model` and in `evse/part-number` — the same concept under two names — and gives PV neither. `schema_1` used to cross over to preserve each entity's displayed meaning, which worked and permanently encoded flat's
18
+ irregularity in the snapshot. This adapter now normalises instead: `bess/model` → `part_number`, `bess/product-name` → `model`. **`battery.model` changes value for existing flat users at this upgrade.** Measured: every EVSE identity field now reads
19
+ identically on both adapters, so for that device class identity stops being a migration delta at all.
20
+
21
+ ### Added
22
+
23
+ - **`dominant_power_source_payload`.** Flat already speaks this vocabulary, so the value passes through — the method exists because `schema_1` must translate, and a caller should not have to know which schema is underneath. Validated rather than passed
24
+ blindly: an unrecognised value returns `None` and the transport refuses the command, matching `schema_1` rather than putting a string outside the enum on the wire.
25
+
10
26
  ## [1.0.0b2] - 08/2026
11
27
 
12
28
  Pre-release. Follows the reshaped `SchemaAdapter` protocol released in `span-panel-api` 3.0.0b2.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api-schema-0"
3
- version = "1.0.0b2"
3
+ version = "1.0.0b3"
4
4
  description = "Flat-schema (data-model-version absent) parser for span-panel-api"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -74,5 +74,22 @@ class SchemaZeroAdapter:
74
74
  return None
75
75
  return PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=core_node, prop="dominant-power-source")
76
76
 
77
+ def dominant_power_source_payload(self, value: str) -> str | None:
78
+ """Flat speaks this vocabulary already, so the caller's value passes through.
79
+
80
+ The method exists because `schema_1` has to translate — its successor
81
+ property accepts `NONE`/`ON_GRID`/`OFF_GRID`, not a source class — and a
82
+ caller should not have to know which schema it is talking to. Here the
83
+ translation is the identity.
84
+
85
+ Validated rather than passed blindly: an unrecognised value returns None
86
+ and the transport refuses the command, which matches `schema_1`'s
87
+ behaviour and is better than putting a string outside the enum on the
88
+ wire.
89
+ """
90
+ allowed = {"GRID", "BATTERY", "PV", "GENERATOR", "NONE", "UNKNOWN"}
91
+ candidate = value.strip().upper()
92
+ return candidate if candidate in allowed else None
93
+
77
94
  def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]:
78
95
  return self._consumer.register_property_callback(callback)
@@ -322,8 +322,12 @@ class HomieDeviceConsumer:
322
322
  soe_percentage=_parse_float(soc_str) if soc_str else None,
323
323
  soe_kwh=_parse_float(soe_str) if soe_str else None,
324
324
  vendor_name=vn if vn else None,
325
- product_name=pn if pn else None,
326
- model=mdl if mdl else None,
325
+ # Flat is the irregular side: it puts the SKU in `model` on the BESS and in
326
+ # `part-number` on the EVSE, for the same concept. The snapshot speaks v1.0's
327
+ # vocabulary now, so translate rather than mirror -- `product-name` is the
328
+ # designation and flat's `bess/model` is the SKU.
329
+ model=pn if pn else None,
330
+ part_number=mdl if mdl else None,
327
331
  serial_number=sn if sn else None,
328
332
  software_version=sw if sw else None,
329
333
  nameplate_capacity_kwh=_parse_float(nc) if nc else None,
@@ -344,7 +348,7 @@ class HomieDeviceConsumer:
344
348
 
345
349
  return SpanPVSnapshot(
346
350
  vendor_name=vn if vn else None,
347
- product_name=pn if pn else None,
351
+ model=pn if pn else None,
348
352
  nameplate_capacity_w=_parse_float(nc) if nc else None,
349
353
  feed_circuit_id=normalize_circuit_id(feed) if feed else None,
350
354
  relative_position=rel_pos.upper() if rel_pos else None,
@@ -367,7 +371,7 @@ class HomieDeviceConsumer:
367
371
  lock_state=self._acc.get_prop(node_id, "lock-state") or "UNKNOWN",
368
372
  advertised_current_a=_parse_float(adv) if adv else None,
369
373
  vendor_name=self._acc.get_prop(node_id, "vendor-name") or None,
370
- product_name=self._acc.get_prop(node_id, "product-name") or None,
374
+ model=self._acc.get_prop(node_id, "product-name") or None,
371
375
  part_number=self._acc.get_prop(node_id, "part-number") or None,
372
376
  serial_number=self._acc.get_prop(node_id, "serial-number") or None,
373
377
  software_version=self._acc.get_prop(node_id, "software-version") or None,
@@ -82,8 +82,12 @@ _PROPERTY_FIELD_MAP: tuple[tuple[str, str, str], ...] = (
82
82
  (TYPE_BESS, "soc", "battery.soe_percentage"),
83
83
  (TYPE_BESS, "soe", "battery.soe_kwh"),
84
84
  (TYPE_BESS, "vendor-name", "battery.vendor_name"),
85
- (TYPE_BESS, "product-name", "battery.product_name"),
86
- (TYPE_BESS, "model", "battery.model"),
85
+ # Flat's irregularity, translated rather than mirrored: it puts the designation in
86
+ # `product-name` and the SKU in `model` on the BESS, where the EVSE puts the SKU in
87
+ # `part-number`. The snapshot speaks v1.0's vocabulary, so both land on the field
88
+ # that matches the concept.
89
+ (TYPE_BESS, "product-name", "battery.model"),
90
+ (TYPE_BESS, "model", "battery.part_number"),
87
91
  (TYPE_BESS, "serial-number", "battery.serial_number"),
88
92
  (TYPE_BESS, "software-version", "battery.software_version"),
89
93
  (TYPE_BESS, "nameplate-capacity", "battery.nameplate_capacity_kwh"),
@@ -91,7 +95,7 @@ _PROPERTY_FIELD_MAP: tuple[tuple[str, str, str], ...] = (
91
95
  (TYPE_BESS, "grid-state", "panel.grid_state"),
92
96
  # --- PV → pv.* -----------------------------------------------------------
93
97
  (TYPE_PV, "vendor-name", "pv.vendor_name"),
94
- (TYPE_PV, "product-name", "pv.product_name"),
98
+ (TYPE_PV, "product-name", "pv.model"),
95
99
  (TYPE_PV, "nameplate-capacity", "pv.nameplate_capacity_w"),
96
100
  (TYPE_PV, "feed", "pv.feed_circuit_id"),
97
101
  (TYPE_PV, "relative-position", "pv.relative_position"), # IN_PANEL | UPSTREAM | DOWNSTREAM
@@ -100,7 +104,7 @@ _PROPERTY_FIELD_MAP: tuple[tuple[str, str, str], ...] = (
100
104
  (TYPE_EVSE, "lock-state", "evse.lock_state"),
101
105
  (TYPE_EVSE, "advertised-current", "evse.advertised_current_a"),
102
106
  (TYPE_EVSE, "vendor-name", "evse.vendor_name"),
103
- (TYPE_EVSE, "product-name", "evse.product_name"),
107
+ (TYPE_EVSE, "product-name", "evse.model"),
104
108
  (TYPE_EVSE, "part-number", "evse.part_number"),
105
109
  (TYPE_EVSE, "serial-number", "evse.serial_number"),
106
110
  (TYPE_EVSE, "software-version", "evse.software_version"),
@@ -0,0 +1,103 @@
1
+ # Changelog
2
+
3
+ All notable changes to `span-panel-api-schema-1` are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is the parent/child device tree SPAN firmware `r202633+` publishes, identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version
8
+ number. A release here means this parser changed, never that the panel did.
9
+
10
+ ## [0.1.0b3] - 08/2026
11
+
12
+ Pre-release. **Requires `span-panel-api` 3.0.0b3 or newer** — see Fixed.
13
+
14
+ ### Added
15
+
16
+ - **Spec conformance checking.** `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
17
+ implements. It is the consumer counterpart to the simulator's publisher lockfile, and both are pinned to the same specification commit — though the anchor shared between them is the **firmware range**, not that commit, because the specification says what
18
+ a device class _may_ publish while a panel publishes one specific tree.
19
+ - **The 13 capability catalogs this adapter addresses**, byte-copied under `spec/` along with the device-types registry. Vendored rather than depended on because the specification is a git repository of versioned documents, not a package. They exist to be
20
+ checked against, never parsed in production: units and datatypes still come from each device's `$description`, since the catalog is the superset across all hardware rather than a statement about the panel in front of us. Formatting hooks are excluded
21
+ from `spec/`, because a lint fix there would quietly invalidate the byte comparison that makes the copies worth having.
22
+ - **`tests/test_schema_one_conformance.py`**, which asks the consumer's question rather than the publisher's. A publisher asks whether everything it emits is legal, and for it an omission is unremarkable. This asks whether every name the adapter _reads_ is
23
+ one the specification defines — because a consumer addressing a name that no longer exists does not fail, it goes quiet: the property never arrives, metadata lookup returns `None`, and an entity disappears. `ebus-sdk` 0.18.0 removing the `battery`
24
+ capability key in favour of `soc`, with no alias, is exactly that shape.
25
+ - **An explicit SPAN extension allowlist.** Fourteen of the forty-two properties this adapter reads are absent from every catalog — per-phase meter readings, panel link states, circuit `spaces`, the EVSE surface. All are legal, since the specification
26
+ permits properties it has never heard of. They are enumerated with reasons so that a name missing from the catalog must be a deliberate claim about SPAN's vocabulary rather than an unnoticed typo; at runtime the two are indistinguishable. Tests also fail
27
+ when an extension is later adopted upstream, or when one is declared for a property nothing reads.
28
+ - **A peer record and simulator coverage check.** `spec_lock.json` now records the producer this parser is developed against — the SPAN simulator, `role: publisher` — with the specification commit and firmware range it pins, and a captured copy of the tree
29
+ it publishes is vendored alongside the catalogs. Two sides reading different vocabularies is now a test failure rather than something noticed later, and the anchor asserted between them is the **firmware range**, since the specification says what a
30
+ device class may publish while a panel publishes one specific tree.
31
+ - **An explicit record of what the producer does not exercise.** Of the 42 `(capability, property)` pairs this adapter reads, the simulator's captured tree declares 41. The exception is `grid/islanding-state`: the simulator models a MID but its tracked
32
+ config publishes none, so `grid_state` — corrected in `0.1.0b2` to read `islanding-state` rather than `grid-state` — is the single mapping the producer gives no evidence for. Recorded rather than left implicit, because a passing suite otherwise reads as
33
+ coverage it does not have. The entry is rejected once the simulator starts publishing it.
34
+ - **The parser is now driven end to end from what the producer actually publishes.** Every other test in this package runs on a fixture captured off the upstream _generic_ eBus panel simulator, which by construction never carries SPAN's own vocabulary.
35
+ `spec/fixtures/simulator_wire.json` is a capture from SPAN's publisher instead — descriptions, `$state` and all 494 property values across 37 devices — fed in sorted topic order, the way a retained store replays it rather than the way a tree is walked.
36
+ The parser reaches ready on it, sizes the panel from `MAIN_40`, and parses all 30 circuits. Values are deliberately not asserted: the producer's config carries `noise_factor` and its clock advances, so pinning a wattage would fail on every recapture for
37
+ a reason nobody could act on.
38
+ - **Two producer-side gaps are pinned rather than left to be noticed.** `grid_state` stays `None` because nothing instantiates a MID, and every DER — BESS, PV and both EVSEs — declares `info/model` in its `$description` and never publishes a value (PV
39
+ declares five `info` properties and publishes one). The second breaks the single standing obligation eBus places on a publisher, to declare accurately what it publishes, and is invisible to a conformance checker: comparing declarations against catalogs
40
+ cannot see a declaration nothing fulfils. Only a capture carrying values can, which is the argument for this fixture existing. Both are asserted as current expectations, so closing either fails the test that describes it.
41
+
42
+ Provenance (byte comparison against a specification or simulator checkout) is skipped unless `EBUS_SPEC_DIR` / `PANELBENCH_DIR` are set, so conformance and coverage run everywhere while the byte checks stay opportunistic. The wire capture is compared on
43
+ shape rather than bytes for the same reason its values are not asserted. Provenance proves the right bytes were copied; it cannot prove they were understood, which is what the other two are for.
44
+
45
+ ### Fixed
46
+
47
+ - **The conformance check was reading the wrong set of names.** Built from `_PROPERTY_FIELD_MAP` alone, it covered only properties that carry field metadata and silently skipped everything the snapshot mapper reads directly — the MID, `connection`
48
+ feeds/fed-by, `info/direction`. `grid_state`, the most recently corrected mapping in this package, was among them. The read set is now derived from the source itself, so it cannot fall behind the code; that immediately surfaced `info/direction` as a
49
+ fifteenth undeclared extension.
50
+ - **The bootstrap floor is raised to 3.0.0b3**, which is where it should always have been: this parser imports `SpanMidSnapshot`, and 3.0.0b2 does not define it. The declared `>=3.0.0b2` let a resolver pair this wheel with 3.0.0b2 and fail on import.
51
+ Caught before the first release that would have shipped it. `schema-0` keeps its b2 floor; every name it imports is present there, checked rather than assumed.
52
+
53
+ ## [0.1.0b2] - 08/2026
54
+
55
+ Pre-release. Corrects the dependency floor `0.1.0b1` shipped with, and follows the reshaped `SchemaAdapter` protocol released in `span-panel-api` 3.0.0b2.
56
+
57
+ ### Added
58
+
59
+ - **`ADAPTER_CONTRACT = 1`**, declaring which version of the bootstrap-to-adapter contract this parser was built against. Declared as a literal rather than imported from `span_panel_api.protocol`: a value read from the installed bootstrap would agree with
60
+ every bootstrap, which is exactly the disagreement the check exists to find.
61
+
62
+ ### Fixed
63
+
64
+ - **The `span-panel-api` floor was `>=3.0.0b1`, which no published bootstrap could satisfy in practice.** `0.1.0b1` was built against a bootstrap that reads the panel's `data-model-version` and constructs adapters with the whole schema; the only bootstrap
65
+ on PyPI at the time did neither. Its `V2HomieSchema` had no `data_model_version` field at all, so a `1.x` panel could not even be represented, and its factory hardcoded the version to `None` — meaning this adapter was discoverable and never selectable.
66
+ The floor is now `>=3.0.0b2`, the first release where both hold. Nothing was installed against the old floor; the combination was unreachable rather than broken in the field.
67
+
68
+ ## [0.1.0b1] - 08/2026
69
+
70
+ Pre-release. First release as a standalone distribution, and the first parser for the parent/child data model.
71
+
72
+ ### Added
73
+
74
+ - **`SchemaOneAdapter`**, registered as `schema_1` under the `span_panel_api.schema_adapters` entry-point group. A panel reporting `data-model-version` `1.x` resolves to it; a panel without this package installed still gets the named
75
+ `SpanPanelAdapterMissingError`, so installing it is the opt-in.
76
+ - **`ControllerRoutes`** — an `ebus_sdk.MqttControllerTransport` that records `Controller`'s subscriptions instead of making them, so the SDK parses the tree over span-panel-api's own connection to the panel's broker. The adapter is built before a
77
+ connection exists and never receives one; a single wildcard subscription made by the transport layer covers the whole tree, and this routes each message to whichever SDK callback asked for it.
78
+ - **The snapshot mapper.** Sorts the tree by declared device type — never by device id — and maps it onto `SpanPanelSnapshot`: circuits, both lugs, the MID, and the BESS/PV/EVSE devices.
79
+ - **Panel size from `info/model`** via `PANEL_SIZE_BY_MODEL`, which is what restores the unmapped-position entries the integration builds from the difference between total and occupied spaces. `info/spaces` has no format and the panel publishes no size
80
+ property, so the model is the only source; `panel_model_drift()` reports a model the panel declares that we have no size for, because the alternative is a user noticing missing positions.
81
+ - **Field metadata read from each device's `$description`** rather than a schema document. The same capability type exposes different properties on different device classes — `meter` is voltage on the panel, power and energy on a circuit, both currents on
82
+ lugs — so the per-device description is what this panel actually has.
83
+ - **A `py.typed` marker**, so consumers type-check against this package's real annotations.
84
+
85
+ ### Known deviations and deliberate gaps
86
+
87
+ - **`set_dominant_power_source_topic()` returns `None`.** The v1.0 property split into `grid-forming-entity` and `asserted-islanding-state`, which are different controls on different devices rather than a rename. `None` makes the transport reject the
88
+ command instead of publishing where nothing listens; which successor to expose is a product decision.
89
+ - **`dsm_state`, `current_run_config`, `grid_islandable` and `pv.relative_position`** have no direct v1.0 equivalent and are left to the product decisions tracked separately. Fields the mapper declines carry no metadata row, so the integration never
90
+ validates against a field nothing populates.
91
+
92
+ ### Fixed before first release
93
+
94
+ Both found by verifying reconnect against a live broker, and both presented as a healthy connection.
95
+
96
+ - **Messages arriving before the SDK registered a route for them were dropped.** `Controller` learns its topics as it walks the tree, but one subscription delivers the whole tree at once in whatever order the broker replays its retained store. Seeded
97
+ children-first, a 40-space panel parsed as zero circuits. Unrouted messages are now held and released when the matching route appears — the value a per-device subscription would have been given at subscribe time — with a ceiling so an unclaimed subtree
98
+ cannot leak.
99
+ - **Readiness asked only about the root**, so a connection completed with a fraction of its circuits and no panel size. It now waits for every declared device to describe itself, at any depth. Child _state_ is deliberately not required, so an offline DER
100
+ does not block a connection; the model is required only when the root's description declares it.
101
+ - **`grid_state` read the wrong one of the MID's two grid properties.** The MID publishes both `grid/islanding-state` (`ON_GRID`/`OFF_GRID`/`UNKNOWN`) and `grid/grid-state` (`UP`/`DOWN`/`DEGRADED`/`UNKNOWN`). The flat schema's `grid_state` was the BESS's
102
+ `grid-state`, an islanding answer, so its successor is `islanding-state`; `grid/grid-state` asks whether the utility supply is healthy and is new in v1.0 with no flat equivalent. Matching on the property name rather than the value set put `UP` where a
103
+ consumer expects `ON_GRID` — an entity keeping its id and history while its vocabulary silently changed. `grid/grid-state` is left unmapped, being a new signal rather than a replacement for an existing field.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api-schema-1"
3
- version = "0.1.0b2"
3
+ version = "0.1.0b3"
4
4
  description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -9,12 +9,17 @@ readme = "README.md"
9
9
  license = "MIT"
10
10
  requires-python = ">=3.10,<4.0"
11
11
  dependencies = [
12
- "span-panel-api>=3.0.0b2,<4.0",
12
+ # b3, not b2: this parser imports `SpanMidSnapshot`, which 3.0.0b2 does not
13
+ # define. A `>=3.0.0b2` floor lets a resolver pair this wheel with 3.0.0b2 and
14
+ # fail on import -- the precise hazard RELEASE.md warns about under "Releasing
15
+ # every distribution". schema-0 keeps its b2 floor; every name it imports is
16
+ # present there, checked rather than assumed.
17
+ "span-panel-api>=3.0.0b3,<4.0",
13
18
  # Only this distribution depends on the eBus SDK. The bootstrap and
14
19
  # schema-0 stay clean, so a flat-panel install never pulls it in — which is
15
20
  # what bounds the release coupling this dependency introduces to panels on
16
21
  # r202633+.
17
- "ebus-sdk>=0.18.0,<1.0",
22
+ "ebus-sdk>=0.19.0,<1.0",
18
23
  ]
19
24
 
20
25
  [project.urls]
@@ -0,0 +1,52 @@
1
+ {
2
+ "$schema": "https://ebus.energy/schemas/property-catalog.json",
3
+ "schema_version": "property-schema-v1",
4
+ "kind": "capability-catalog",
5
+ "capability": "energy.ebus.capability.breaker",
6
+ "version": "0.1",
7
+ "status": "DRAFT",
8
+ "date": "2026-07-11",
9
+ "properties": {
10
+ "rating": {
11
+ "datatype": "integer",
12
+ "unit": "A",
13
+ "req": "SHOULD",
14
+ "description": "Continuous current rating."
15
+ },
16
+ "poles": {
17
+ "datatype": "integer",
18
+ "req": "MAY",
19
+ "description": "Number of poles (1-4). A US split-phase 240 V breaker is `2`."
20
+ },
21
+ "interrupting-rating": {
22
+ "datatype": "integer",
23
+ "unit": "kA",
24
+ "req": "MAY",
25
+ "description": "Interrupting capacity (kAIC), e.g. `10`, `65`, `100`."
26
+ },
27
+ "protection-functions": {
28
+ "datatype": "enum",
29
+ "format": "OVERCURRENT,SHORT_CIRCUIT,GROUND_FAULT,ARC_FAULT",
30
+ "req": "MAY",
31
+ "description": "Multi-valued set of the protections this breaker provides: `OVERCURRENT`, `SHORT_CIRCUIT`, `GROUND_FAULT` (GFCI), `ARC_FAULT` (AFCI)."
32
+ },
33
+ "trip-curve": {
34
+ "datatype": "enum",
35
+ "format": "B,C,D,K",
36
+ "req": "MAY",
37
+ "description": "Instantaneous trip curve: `B`, `C`, `D`, `K`, …"
38
+ },
39
+ "trip-state": {
40
+ "datatype": "enum",
41
+ "format": "OK,TRIPPED,STUCK,UNKNOWN,CLOSED",
42
+ "req": "SHOULD",
43
+ "description": "`OK`, `TRIPPED`, `STUCK`, `UNKNOWN`. A tripped breaker carries no current even if a co-located `switch/relay` reads `CLOSED`, so `trip-state` is not a relay state."
44
+ },
45
+ "trip-cause": {
46
+ "datatype": "enum",
47
+ "format": "OVERCURRENT,SHORT_CIRCUIT,GROUND_FAULT,ARC_FAULT,OVERVOLTAGE,UNKNOWN",
48
+ "req": "MAY",
49
+ "description": "Cause of the most recent trip: `OVERCURRENT`, `SHORT_CIRCUIT`, `GROUND_FAULT`, `ARC_FAULT`, `OVERVOLTAGE`, `UNKNOWN`."
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,72 @@
1
+ {
2
+ "$schema": "https://ebus.energy/schemas/property-catalog.json",
3
+ "schema_version": "property-schema-v1",
4
+ "kind": "capability-catalog",
5
+ "capability": "energy.ebus.capability.connection",
6
+ "version": "0.1",
7
+ "status": "DRAFT",
8
+ "date": "2026-07-05",
9
+ "properties": {
10
+ "feeds-device-id": {
11
+ "datatype": "string",
12
+ "req": "MAY",
13
+ "description": "Homie device ID of the device wired *downstream* of this connection point. Published only when the specific downstream device is known. Omitted when unknown, when mixed-load with no commissioned downstream device, or when nothing is connected."
14
+ },
15
+ "feeds-device-type": {
16
+ "datatype": "string",
17
+ "req": "MAY",
18
+ "description": "`$description.type` of the downstream device (e.g. `energy.ebus.device.bess`, `.pv`, `.evse`, `.water-heater`, `.distribution-enclosure`, or a DER sub-device such as `.battery`). Published when the class is known even if the specific ID is not."
19
+ },
20
+ "feeds-device-status": {
21
+ "datatype": "enum",
22
+ "format": "OK,LOST,DEGRADED",
23
+ "req": "MAY",
24
+ "description": "Publisher's view of communication-link health to the downstream device: `OK`, `LOST`, `DEGRADED`. Published only when `feeds-device-id` is published and the publisher has a communication integration with that device."
25
+ },
26
+ "fed-by-device-id": {
27
+ "datatype": "string",
28
+ "req": "MAY",
29
+ "description": "Homie device ID of the device wired *upstream* of this connection point. Published only when known (e.g. an upstream BESS wired between the utility and the enclosure, or an upstream sister enclosure in a chain). Omitted when the upstream side is the utility, an implicit busbar, or unknown."
30
+ },
31
+ "fed-by-device-type": {
32
+ "datatype": "string",
33
+ "req": "MAY",
34
+ "description": "`$description.type` of the upstream device. Published with `fed-by-device-id`."
35
+ },
36
+ "fed-by-device-status": {
37
+ "datatype": "enum",
38
+ "format": "OK,LOST,DEGRADED",
39
+ "req": "MAY",
40
+ "description": "Publisher's view of communication-link health to the upstream device. Same value domain and applicability as `feeds-device-status`."
41
+ },
42
+ "backed-up": {
43
+ "datatype": "enum",
44
+ "format": "BACKED_UP,NOT_BACKED_UP,UNKNOWN",
45
+ "req": "MAY",
46
+ "description": "Whether this path is on the backup (island) side of a microgrid interconnect device, and so stays energized off-grid: `BACKED_UP`, `NOT_BACKED_UP`, `UNKNOWN`. A wiring fact (which side of the interconnect), distinct from `load-shed/priority` (a shedding *policy*) and `grid/islanding-state` (the present *state*)."
47
+ },
48
+ "feeds-role": {
49
+ "datatype": "enum",
50
+ "format": "LOADS,SUBPANEL,SOLAR,STORAGE,GENERATOR,MIXED,UNUSED",
51
+ "req": "MAY",
52
+ "description": "Summary role of a downstream node that is **not** published as its own eBus device, or that is surveyed-empty: `LOADS`, `SUBPANEL`, `SOLAR`, `STORAGE`, `GENERATOR`, `MIXED`, `UNUSED`. `UNUSED` positively records \"surveyed, nothing connected\" (which absence cannot express). Complements `feeds-device-*`, which is used when the downstream *is* an eBus device."
53
+ },
54
+ "service-rating": {
55
+ "datatype": "integer",
56
+ "unit": "A",
57
+ "req": "MAY",
58
+ "description": "Utility service rating (service size) at a service-entrance connection point. Distinct from `pcs/feed-import-limit` (a PCS enforcement limit) and `breaker/rating` (a main breaker)."
59
+ },
60
+ "overcurrent-protection": {
61
+ "datatype": "integer",
62
+ "unit": "A",
63
+ "req": "MAY",
64
+ "description": "Overcurrent-protection rating at a connection point that is not itself a breaker-protected circuit (for example a feeder conductor landing in unprotected lugs). Where the connection point *is* a breaker-protected circuit, the rating is `breaker/rating` instead."
65
+ },
66
+ "count": {
67
+ "datatype": "integer",
68
+ "req": "MAY",
69
+ "description": "When the connected node aggregates multiple physical units behind a *single* connection point (e.g. 6 battery packs in one BESS, or 4 microinverters on one AC string reported as one solar device), how many."
70
+ }
71
+ }
72
+ }