span-panel-api 3.0.0b2__tar.gz → 3.0.0b6__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 (147) hide show
  1. span_panel_api-3.0.0b6/.gitignore +47 -0
  2. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/CHANGELOG.md +149 -0
  3. span_panel_api-3.0.0b2/README.md → span_panel_api-3.0.0b6/PKG-INFO +52 -1
  4. span_panel_api-3.0.0b2/PKG-INFO → span_panel_api-3.0.0b6/README.md +33 -16
  5. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/pyproject.toml +54 -7
  6. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/src/span_panel_api/__init__.py +38 -0
  7. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/src/span_panel_api/adapters.py +98 -67
  8. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/src/span_panel_api/factory.py +5 -1
  9. span_panel_api-3.0.0b6/src/span_panel_api/models.py +993 -0
  10. span_panel_api-3.0.0b6/src/span_panel_api/mqtt/client.py +1055 -0
  11. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/src/span_panel_api/protocol.py +69 -0
  12. {span_panel_api-3.0.0b2/tests/fixtures/v2 → span_panel_api-3.0.0b6/src/span_panel_api/reference_payloads}/README.md +7 -11
  13. span_panel_api-3.0.0b6/src/span_panel_api/reference_payloads/__init__.py +66 -0
  14. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/conftest.py +30 -0
  15. span_panel_api-3.0.0b6/tests/fixtures/flat_wire.json +563 -0
  16. span_panel_api-3.0.0b6/tests/fixtures/panelbench_unvalued_by_both.json +125 -0
  17. span_panel_api-3.0.0b6/tests/fixtures/v2/README.md +14 -0
  18. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_adapters_discovery.py +62 -14
  19. span_panel_api-3.0.0b6/tests/test_adopted_control.py +188 -0
  20. span_panel_api-3.0.0b6/tests/test_adoption.py +414 -0
  21. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_auth_and_homie_helpers.py +0 -1
  22. span_panel_api-3.0.0b6/tests/test_catalog_divergence.py +685 -0
  23. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_detection_auth.py +3 -7
  24. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_factory_dispatch.py +1 -1
  25. span_panel_api-3.0.0b6/tests/test_field_metadata.py +726 -0
  26. span_panel_api-3.0.0b6/tests/test_live_flat_differential.py +166 -0
  27. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_mqtt_client_connection.py +36 -0
  28. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_mqtt_connect_flow.py +76 -2
  29. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_mqtt_homie.py +7 -7
  30. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_protocol_conformance.py +8 -0
  31. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_protocol_models.py +0 -1
  32. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_public_api_unchanged.py +35 -0
  33. span_panel_api-3.0.0b6/tests/test_redispatch_on_reconnect.py +301 -0
  34. span_panel_api-3.0.0b6/tests/test_reference_tree_values.py +140 -0
  35. span_panel_api-3.0.0b6/tests/test_schema_generation_cross_check.py +119 -0
  36. span_panel_api-3.0.0b6/tests/test_schema_migration_delta.py +579 -0
  37. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_schema_one_adapter.py +174 -6
  38. span_panel_api-3.0.0b6/tests/test_schema_one_against_simulator.py +260 -0
  39. span_panel_api-3.0.0b6/tests/test_schema_one_charge_limit.py +540 -0
  40. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_schema_one_circuits.py +6 -17
  41. span_panel_api-3.0.0b6/tests/test_schema_one_conformance.py +687 -0
  42. span_panel_api-3.0.0b6/tests/test_schema_one_connection_health.py +371 -0
  43. span_panel_api-3.0.0b6/tests/test_schema_one_devices.py +488 -0
  44. span_panel_api-3.0.0b6/tests/test_schema_one_discovery.py +543 -0
  45. span_panel_api-3.0.0b6/tests/test_schema_one_panel.py +582 -0
  46. span_panel_api-3.0.0b6/tests/test_schema_one_pcs.py +522 -0
  47. span_panel_api-3.0.0b6/tests/test_schema_one_service_entrance.py +196 -0
  48. span_panel_api-3.0.0b6/tests/test_schema_one_shed_forecast.py +289 -0
  49. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_schema_one_snapshot.py +29 -25
  50. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_schema_provenance.py +8 -8
  51. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_schema_zero_adapter.py +32 -0
  52. span_panel_api-3.0.0b2/.codefactor +0 -29
  53. span_panel_api-3.0.0b2/.codefactor.yml +0 -18
  54. span_panel_api-3.0.0b2/.deps-installed +0 -0
  55. span_panel_api-3.0.0b2/.github/ISSUE_TEMPLATE/bug_report.md +0 -37
  56. span_panel_api-3.0.0b2/.github/ISSUE_TEMPLATE/feature_request.md +0 -31
  57. span_panel_api-3.0.0b2/.github/dependabot.yml +0 -62
  58. span_panel_api-3.0.0b2/.github/workflows/ci.yml +0 -136
  59. span_panel_api-3.0.0b2/.github/workflows/dependabot-auto-approve.yml +0 -38
  60. span_panel_api-3.0.0b2/.github/workflows/dependabot-auto-merge.yml +0 -56
  61. span_panel_api-3.0.0b2/.github/workflows/release.yml +0 -119
  62. span_panel_api-3.0.0b2/.gitignore +0 -36
  63. span_panel_api-3.0.0b2/.markdownlint-cli2.jsonc +0 -49
  64. span_panel_api-3.0.0b2/.markdownlint.json +0 -11
  65. span_panel_api-3.0.0b2/.pre-commit-config.yaml +0 -143
  66. span_panel_api-3.0.0b2/.prettierrc.json +0 -25
  67. span_panel_api-3.0.0b2/.vscode/extensions.json +0 -5
  68. span_panel_api-3.0.0b2/.vscode/tasks.json +0 -131
  69. span_panel_api-3.0.0b2/DEVELOPMENT.md +0 -101
  70. span_panel_api-3.0.0b2/RELEASE.md +0 -193
  71. span_panel_api-3.0.0b2/SECURITY.md +0 -11
  72. span_panel_api-3.0.0b2/conftest.py +0 -18
  73. span_panel_api-3.0.0b2/developer_attribute_readme.md +0 -276
  74. span_panel_api-3.0.0b2/openapi.json +0 -1621
  75. span_panel_api-3.0.0b2/packages/schema-0/CHANGELOG.md +0 -51
  76. span_panel_api-3.0.0b2/packages/schema-0/README.md +0 -32
  77. span_panel_api-3.0.0b2/packages/schema-0/pyproject.toml +0 -35
  78. span_panel_api-3.0.0b2/packages/schema-0/src/span_panel_api_schema_0/__init__.py +0 -11
  79. span_panel_api-3.0.0b2/packages/schema-0/src/span_panel_api_schema_0/accumulator.py +0 -274
  80. span_panel_api-3.0.0b2/packages/schema-0/src/span_panel_api_schema_0/adapter.py +0 -78
  81. span_panel_api-3.0.0b2/packages/schema-0/src/span_panel_api_schema_0/const.py +0 -83
  82. span_panel_api-3.0.0b2/packages/schema-0/src/span_panel_api_schema_0/consumer.py +0 -641
  83. span_panel_api-3.0.0b2/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py +0 -176
  84. span_panel_api-3.0.0b2/packages/schema-1/CHANGELOG.md +0 -60
  85. span_panel_api-3.0.0b2/packages/schema-1/README.md +0 -8
  86. span_panel_api-3.0.0b2/packages/schema-1/pyproject.toml +0 -42
  87. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/__init__.py +0 -6
  88. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/adapter.py +0 -260
  89. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/circuits.py +0 -177
  90. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/const.py +0 -115
  91. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/devices.py +0 -143
  92. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +0 -169
  93. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/panel.py +0 -291
  94. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/py.typed +0 -0
  95. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +0 -155
  96. span_panel_api-3.0.0b2/packages/schema-1/src/span_panel_api_schema_1/transport.py +0 -194
  97. span_panel_api-3.0.0b2/pytest.ini +0 -2
  98. span_panel_api-3.0.0b2/pytest_output.log +0 -11
  99. span_panel_api-3.0.0b2/scripts/__init__.py +0 -1
  100. span_panel_api-3.0.0b2/scripts/coverage.py +0 -118
  101. span_panel_api-3.0.0b2/scripts/format.sh +0 -25
  102. span_panel_api-3.0.0b2/scripts/format_markdown.py +0 -39
  103. span_panel_api-3.0.0b2/scripts/test_live_auth.py +0 -311
  104. span_panel_api-3.0.0b2/scripts/verify_adapterless_install.py +0 -88
  105. span_panel_api-3.0.0b2/scripts/verify_reconnect.py +0 -530
  106. span_panel_api-3.0.0b2/setup-hooks.sh +0 -31
  107. span_panel_api-3.0.0b2/src/span_panel_api/models.py +0 -239
  108. span_panel_api-3.0.0b2/src/span_panel_api/mqtt/client.py +0 -588
  109. span_panel_api-3.0.0b2/src/span_panel_api/py.typed +0 -0
  110. span_panel_api-3.0.0b2/tests/fixtures/parent_child_tree.json +0 -225
  111. span_panel_api-3.0.0b2/tests/test_field_metadata.py +0 -281
  112. span_panel_api-3.0.0b2/tests/test_schema_one_devices.py +0 -169
  113. span_panel_api-3.0.0b2/tests/test_schema_one_panel.py +0 -266
  114. span_panel_api-3.0.0b2/uv.lock +0 -1561
  115. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/LICENSE +0 -0
  116. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/src/span_panel_api/_http.py +0 -0
  117. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/src/span_panel_api/auth.py +0 -0
  118. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/src/span_panel_api/const.py +0 -0
  119. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/src/span_panel_api/detection.py +0 -0
  120. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/src/span_panel_api/dispatch.py +0 -0
  121. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/src/span_panel_api/exceptions.py +0 -0
  122. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/src/span_panel_api/mqtt/__init__.py +0 -0
  123. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/src/span_panel_api/mqtt/async_client.py +0 -0
  124. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/src/span_panel_api/mqtt/connection.py +0 -0
  125. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/src/span_panel_api/mqtt/const.py +0 -0
  126. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/src/span_panel_api/mqtt/models.py +0 -0
  127. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/src/span_panel_api/phase_validation.py +0 -0
  128. {span_panel_api-3.0.0b2/packages/schema-0/src/span_panel_api_schema_0 → span_panel_api-3.0.0b6/src/span_panel_api}/py.typed +0 -0
  129. {span_panel_api-3.0.0b2/tests/fixtures/v2 → span_panel_api-3.0.0b6/src/span_panel_api/reference_payloads}/homie_schema.json +0 -0
  130. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/src/span_panel_api/schema_drift.py +0 -0
  131. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  132. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  133. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  134. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/fixtures/v2/status.json +0 -0
  135. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/simulation_fixtures/circuits.response.txt +0 -0
  136. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/simulation_fixtures/panel.response.txt +0 -0
  137. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/simulation_fixtures/soe.response.txt +0 -0
  138. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/simulation_fixtures/status.response.txt +0 -0
  139. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_accumulator.py +0 -0
  140. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_async_mqtt_client.py +0 -0
  141. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_exceptions.py +0 -0
  142. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_mqtt_bridge.py +0 -0
  143. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_mqtt_debounce.py +0 -0
  144. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_packaging.py +0 -0
  145. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_phase_validation_configs.py +0 -0
  146. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_phase_validation_errors.py +0 -0
  147. {span_panel_api-3.0.0b2 → span_panel_api-3.0.0b6}/tests/test_schema_one_transport.py +0 -0
@@ -0,0 +1,47 @@
1
+ __pycache__/
2
+ build/
3
+ dist/
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ .cursor/
7
+ .cursorignore
8
+ .cursor
9
+ .cursorindexingignore
10
+ xnotes/*
11
+
12
+ # pyenv
13
+ .python-version
14
+
15
+ # Environments
16
+ .env
17
+ .envrc
18
+ .venv
19
+ .vscode/settings.json
20
+
21
+ # mypy
22
+ .mypy_cache/
23
+ .dmypy.json
24
+ dmypy.json
25
+
26
+ # ruff
27
+ .ruff_cache/
28
+
29
+ # JetBrains
30
+ .idea/
31
+
32
+ /coverage.xml
33
+ /.coverage
34
+ coverage_output.log
35
+ **/.DS_Store
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
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/
@@ -4,6 +4,155 @@ 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.0b6]
8
+
9
+ ### Added
10
+
11
+ - **`SpanPanelSnapshot.lugs_at_service_entrance`, saying whether this enclosure's upstream lugs are the utility connection point.** `instant_grid_power_w` is those lugs' `meter/active-power`, and the name holds only at the service entrance: a BESS wired
12
+ ahead of the main lugs, or an enclosure fed by another enclosure, leaves the lugs metering panel-side flow while the utility side differs by whatever that device contributes or absorbs. `power_flow_grid` stays site-level and correct in both, so the two
13
+ legitimately disagree — and before this a consumer seeing them disagree could not tell a topology from a fault. Sourced from the lugs' `connection/fed-by-device-id`, which `power-flows` 0.3 names as the detection mechanism when it qualifies its own
14
+ negation table; this library already read that property and then discarded it, so no consumer could compute this for itself. Defaults `True` because flat firmware predates chaining and a flat panel's lugs really are its service entrance, so schema_0
15
+ leaves it alone. Additive, so it costs no protocol member and no contract bump. Worth knowing: the reference capture publishes `fed-by-device-id: bess` on its upstream lugs, so the reference panel reports `False`.
16
+
17
+ - **An adopted device carries the proxy link it declares: `AdoptedDevice.parent` and `AdoptedDevice.proxied`.** Carried rather than acted on — an adopted device is still registered under the enclosure — because a _proxied_ unmodelled device is a real shape
18
+ that would otherwise be flattened away unrecorded. The reference tree already contains one: `bess-mid` declares `parent: bess`, the `{proxier-id}-{proxied-id}` naming of `devices/proxy.md`. `proxied` is derived against the tree `root` in the adapter,
19
+ because device ids are opaque and a consumer holding one device cannot tell the enclosure's id from a sibling's.
20
+ - **The nesting is deliberately not built yet.** [python-sdk#49](https://github.com/electrification-bus/python-sdk/issues/49#issuecomment-5359203067) records that proxied ids differ by design and that consumers correlate by `info/serial-number` rather than
21
+ by device id, and `ebus-sdk` 0.21.0 shipped `DeviceSpec`/`DeviceTreeBuilder` ([python-sdk#57](https://github.com/electrification-bus/python-sdk/issues/57)) with the graph builder still to be reconciled against it. The tree model is being reshaped
22
+ upstream, so the fields capture the evidence and the topology waits.
23
+
24
+ - **A settable property on an adopted device can be written, and the write cannot reach anything else: `AdoptedProperty.set_topic` and `SpanMqttClient.set_adopted_property`.** The topic is populated only for a settable property on a device `is_modelled`
25
+ rejects, so it is the scoping that authorises the write rather than a check a caller has to remember. The transport resolves the property against the current snapshot's `adopted_devices` and publishes to the topic that property carries; no topic is
26
+ accepted from the caller, and a device this library models produces no `AdoptedDevice` to find.
27
+ - **The alternative was a `set_property_topic` member on `SchemaAdapter`, and it was rejected for two independent reasons.** It would have put every curated control one argument away, and two of them do real work on the way out —
28
+ `dominant_power_source_payload` translates `GRID` into the `ON_GRID` the v1.0 islanding assertion accepts, and `evse_charge_limit_payload` refuses a value above the commissioned ceiling because publishing past it is the one write with a physical
29
+ consequence. It would also have been required of every adapter package, since `_derive_required_members` derives the required set from the protocol, so an installation carrying an older adapter wheel would have failed at _discovery_ rather than losing
30
+ one feature.
31
+ - **No translation and no bounds check on an adopted write, deliberately.** Both exist on curated controls because this library knows what those properties mean. It knows nothing about an adopted one beyond its declaration, and inventing a bound would be
32
+ inventing a fact about somebody else's hardware. The consumer constrains the value to the declared `format`; the panel stays the authority on whether to accept it.
33
+ - **`AdoptedControlProtocol`**, so a consumer asks `isinstance` before offering the control, exactly as it does for circuit, panel and EVSE control.
34
+
35
+ - **A device type this adapter models nothing for is reported whole rather than ignored: `SpanPanelSnapshot.adopted_devices`.** `TreeRoles` sorts the tree into the roles the snapshot needs, and anything that matches none of them has always fallen off the
36
+ end silently — a panel publishing a device nobody modelled produced no field, no metadata row and no sign it was there. The schema is explicitly vendor-extensible, so that is an expected arrival rather than a hypothetical one. `AdoptedDevice` carries the
37
+ device's identity and its readings; `span_panel_api_schema_1.adoption` builds one per unmodelled child.
38
+ - **The unit is a device, never a property, and that is the whole design.** A new property on a device this adapter already models is a curation task with a short turnaround, and surfacing it automatically spends a consumer's entity identity permanently on
39
+ a shape a human would likely have chosen differently — the sixteen `pcs` properties that curation collapsed into one entity and thirteen attributes are the worked example. An unmodelled _type_ is the opposite case: no curation is coming, so the silence
40
+ is the only alternative. Extra instances of a modelled type are deliberately not adopted either: a second BESS is a multiplicity limit, not an unmodelled device, and adopting it would stand a machine-named record beside a curated one for the same
41
+ hardware.
42
+ - **`info` and `connection` resolve away from readings, by node rather than by property name.** `info` is a device's build identity and becomes the card fields `AdoptedDevice` carries; `connection` is topology and becomes the device link. The partition is
43
+ keyed on the node because the catalogs carry no marker for "this string is a device reference", which leaves a hard-coded name list as the only alternative — and such a list goes stale silently: `ebus-sdk`'s own `topology.py` covers `feeds-device-id` and
44
+ `fed-by-device-id` and omits `grid-forming-entity`, which lives on the `grid` capability. A node is what the vocabulary defines, so keying on it cannot go stale the same way.
45
+ - **`AdoptedProperty` carries the value; `DiscoveredMetadata` still must not.** The two answer opposite questions and are separate types so that conflating them is a type error. Discovery rows are built to be forwarded in consumer diagnostics, which leave
46
+ the machine, so they carry declarations only. An adopted property exists to become an entity on the machine that built it, so it carries the reading — along with the declared `format` and `settable` flag, which are together the value domain a consumer
47
+ needs to build a control rather than a reading.
48
+ - **Additive, and deliberately not a protocol member.** `adopted_devices` defaults to `()`, so schema_0 — which has no device tree to find an unmodelled device in — is untouched, and `ADAPTER_CONTRACT_VERSION` does not move. `SchemaAdapter` derives its
49
+ required members from itself, so a member there would be required of every adapter package and would invalidate built wheels.
50
+
51
+ - **The capability catalogs are used as a validator, not just as a vocabulary list: `span_panel_api_schema_1.catalog`.** Sixteen catalogs have been vendored since v1.0 landed and were read only to assert that a catalog _exists_ for every node the adapter
52
+ addresses. Nothing compared a declared `unit` or `datatype` against the catalog's definition of the same property, which is the comparison that catches a mislabel — and the one mislabel this repository has met (`meter/active-power` declared `kW` while
53
+ the values are watts, a 1000x error) was found because a person noticed a sibling device declaring the same quantity differently. The new module compares one declaration against one catalog definition and classifies the result;
54
+ `tests/test_catalog_divergence.py` runs it across all four vendored producer captures and holds the outcome against an acknowledged-divergence register.
55
+ - **Agreement is silence; disagreement is surfaced, never silently resolved.** A finding is not a licence to change a wire reader to match the catalog, nor to assume the catalog is right — both sides have been wrong. It is recorded in `_REGISTER` with what
56
+ the wire says, what the catalog says, which producers show it, a reason and a date, and the baseline fails in both directions: a new divergence fails until somebody records it, and a recorded divergence that has **disappeared** fails until its line is
57
+ removed. That second direction is what keeps the register self-cleaning rather than a suppression list.
58
+ - **An abstract unit is a dimension, and comparing it as a string would report conformance as the defect.** `soc/soe`, `soc/total-energy-storage`, `soc/loadup-headroom` and `info/nameplate-capacity` are all `unit: "energy"`, which the specification
59
+ requires a publisher to substitute a real unit for — a BESS in kWh, a water heater in Wh. `UNIT_FAMILIES` enumerates membership rather than deriving it from an SI-prefix rule, so a member is silent, echoing the placeholder back is a finding, and an
60
+ energy unit nobody enumerated is a question for a human. A catalog unit token that is neither a known family nor a known concrete unit fails until it is classified, so a new abstract family upstream cannot arrive as sixty false findings.
61
+ - **An absence is terminal and is reported once.** A property no catalog defines — the EVSE's `config` node, which is not an eBus capability at all, and the `status`/`meter`/`info` extensions SPAN publishes — has no definition to disagree with, so it is
62
+ reported as absent rather than as every field mismatching against nothing. That keeps `_SPAN_EXTENSIONS` the single home for the read-set half of that question instead of duplicating its judgements here.
63
+ - **The flat schema document is surveyed too, and it is where the known mislabel lives.** It declares properties per device type with no capability node to look a catalog up by, so its properties reach the catalogued vocabulary through the snapshot field
64
+ path both adapters' metadata tables already name — derived from those tables rather than restated, so the join cannot outlive them. The join is admitted only where the two sides spell the property identically: fifteen flat properties reach a catalogued
65
+ property under a different name (`dipole` for `breaker/poles`, `software-version` for `info/firmware-version`), and comparing across a rename would invent divergences out of the pre-catalog spelling that having two adapters already handles.
66
+
67
+ - **Per-DER connection health reaches the snapshot: `SpanEvseSnapshot.connected` and `SpanPVSnapshot.connected`.** `battery.connected` has carried the enclosure's view of the link to the BESS since v1.0 landed, from the upstream lugs'
68
+ `connection/fed-by-device-status`. The other half of the same capability — a circuit's `connection/feeds-device-status`, which is how the enclosure reports the link to a PV or a charger — reached nothing, so only one of a panel's three DER classes had a
69
+ link-health field. Both new fields are `bool | None` and mirror `battery.connected` exactly, read by `build_pv` and `build_evse` through the new `feed_connection_statuses`.
70
+ - **`None` is the specification's "unknown", and it is load-bearing.** The enum is `OK,LOST,DEGRADED` with no `UNKNOWN` member, so an unpublished property is the only way a panel can say it does not know — and `distribution-enclosure.md` states that a
71
+ mixed-load or unsurveyed circuit publishes no connection record at all, which is the normal state for most of a panel's circuits. So absence is never a fault: a DER no circuit claims, or one whose circuit publishes an id without a status, reports `None`
72
+ rather than `False`. `DEGRADED` collapses to `False`, because the question this field answers is whether the enclosure can talk to the device.
73
+ - **The charger's link is not the charger's session.** `evse.status` is the OCPP-style state the charger reports about the cable in front of it; `evse.connected` is the enclosure reporting whether it can reach the charger at all. A charger mid-session over
74
+ a lost link publishes `CHARGING` and `connected=False` at once, and the two fields stay separate for the same reason `battery.connected` and `battery.communication_state` do.
75
+ - **`_PROPERTY_FIELD_MAP` rows for both**, from `(circuit, connection, feeds-device-status)` — the one place a row's device type and its field path deliberately differ, because v1.0 states the relationship on the circuit and the field belongs to the DER.
76
+ One property carries two rows, since one circuit's record describes a PV and another's a charger. Both buy the datatype the circuit's own `$description` declares plus the three-way resolution contract.
77
+
78
+ - **The BESS's own meter and link health reach the snapshot: `SpanBatterySnapshot.power_w` and `SpanBatterySnapshot.communication_state`.** The battery device has published `meter/active-power` and `status/communication-state` all along and neither reached
79
+ a field, so a consumer could show the enclosure's arbitrated `power_flow_battery` and nothing the BESS itself reports. Both are `None` on a BESS that publishes no such node, and on every flat panel — the flat schema's BESS device class declares neither
80
+ property, so this is new surface rather than a re-sourcing, and nothing that exists today changes.
81
+ - **`power_w` is discharge-positive, and the wire is not.** The enclosure meters the BESS the way it meters a circuit it feeds, so a _discharging_ battery publishes a negative `meter/active-power`; `build_battery` negates it, exactly as `build_circuit`
82
+ does for a load. Positive therefore means power flowing _out of_ the battery. This entry said charge-positive until the direction was settled by measurement rather than by reading: with the producer driven into self-consumption and the grid at exactly
83
+ zero — PV 4181 W plus battery 1917 W meeting a 6099 W load, so the battery can only be discharging — the snapshot reported `+1917.49`. `_charge_positive` was renamed `_discharge_positive` in the same pass. No published value changed; the negation was
84
+ always there and always right, and only the name and this note asserted a direction the code did not hold.
85
+ - The asymmetry with `panel.power_flow_battery` is real and unchanged: the enclosure's own arbitrated figure is passed through untouched by both adapters and is charge-positive, so it reads negative for the same discharging battery that makes `power_w`
86
+ positive. The two describe the same physical power in opposite frames, and a consumer rendering both negates one of them — which is what the Home Assistant integration does, landing both of its entities on discharge-positive.
87
+ - **`communication_state` stays the published enum string** (`OK`/`DEGRADED`/`LOST`/`UNKNOWN`) rather than collapsing to a bool: `DEGRADED` is neither `OK` nor `LOST`, and a bool would have to pick one. It is deliberately not merged into
88
+ `battery.connected`, which is the _enclosure's_ `connection/fed-by-device-status` view of the same link. One is the device speaking about itself and the other the panel speaking about it, and the migration guide warns against conflating them.
89
+ - **`_PROPERTY_FIELD_MAP` rows for both**, which buys them the unit and datatype the BESS's own `$description` declares plus the three-way resolution contract — a BESS that publishes the node while omitting the property reports degradation rather than
90
+ absent hardware. The row describes the property; the sign flip the mapper applies is not a unit change.
91
+
92
+ - **`shed-forecast` reaches the snapshot: five new `SpanPanelSnapshot` fields.** `shed_time_to_priority_shed_min`, `shed_total_time_remaining_min`, `shed_full_charge_time_to_priority_shed_min`, `shed_full_charge_total_time_remaining_min` and
93
+ `shed_forecast_confidence`. The enclosure has published `energy.ebus.capability.shed-forecast` 0.1 since r202633 and nothing read it — the backup-planning numbers ("how long before my battery starts shedding circuits", "how long before it is exhausted")
94
+ were on the wire and stopped at the transport. All four times are `integer` minutes as the capability declares, parsed through `panel.integer` so a publisher that serialises a whole number with a decimal point still resolves; `confidence` stays the raw
95
+ `LOW`/`MEDIUM`/`HIGH` string, because it qualifies the four times rather than standing alone. Every field is `None` when the panel publishes no such node, and `None` is load-bearing: zero minutes is a legitimate reading — shedding starts now — so a
96
+ defaulted zero would be indistinguishable from the worst forecast the capability can report. Purely additive; a panel that publishes nothing here is unchanged.
97
+ - **`_PROPERTY_FIELD_MAP` rows for the two live estimates**, `panel.shed_time_to_priority_shed_min` and `panel.shed_total_time_remaining_min`. That buys them the unit and datatype the device's own `$description` declares, and with it the three-way
98
+ resolution contract: a panel that publishes the node while omitting one of the two reports degradation rather than absent hardware. The `full-charge-*` pair and `confidence` deliberately get no row — a consumer renders them beside the two live estimates
99
+ rather than as readings of their own, so there is no unit surface for a row to describe.
100
+ - **`shed-forecast` 0.1 vendored under `packages/schema-1/spec/catalogs/`** and pinned in `spec_lock.json`, byte-copied from the specification at the recorded `synced_commit`. The conformance suite requires a catalog for every capability node the adapter
101
+ addresses, so a node read without one would be unchecked while looking checked.
102
+
103
+ ## [3.0.0b5] - 08/2026
104
+
105
+ Pre-release. Publishes the captured schema document consumers were copying by hand.
106
+
107
+ ### Added
108
+
109
+ - **`span_panel_api.reference_payloads`, shipping `homie_schema.json` as package data.** The captured `GET /api/v2/homie/schema` response moves out of `tests/fixtures/v2/` and into the wheel, reached by `homie_schema()` and `homie_schema_types()` rather
110
+ than by path. It was already being consumed outside this repository: the Home Assistant integration checks the field paths it declares against what an adapter can actually produce, which needs a real schema document, so it vendored a byte copy with a
111
+ README explaining where the copy came from. A copy has no version — it goes stale in silence, and a stale one turns the integration's conformance gate into a check against a schema no panel runs. Shipped, the payload carries the version of the release it
112
+ came with: pin `span-panel-api==3.0.0b5` and you read the bytes that release was written against, with nothing left to keep in sync. `homie_schema_types()` returns `HomieSchemaTypes` — precisely what
113
+ `span_panel_api_schema_0.field_metadata.build_field_metadata` accepts — so a caller building metadata never reaches into an untyped document to get it. This distribution owns the schema document rather than an adapter one because it is the response of
114
+ `get_homie_schema()` here, modelled by `V2HomieSchema` here, and dispatch reads its `data_model_version` to decide which adapter parses the panel at all. The parent/child device tree is the other half and ships from `span-panel-api-schema-1`, with the
115
+ parser that can interpret it.
116
+ - **This suite reads the payload through the same accessor.** `test_schema_provenance.py` and `test_detection_auth.py` no longer open a path, so the schema anchor is checked against the bytes a consumer installs rather than against a file that exists only
117
+ in a checkout.
118
+
119
+ ## [3.0.0b3] - 08/2026
120
+
121
+ Pre-release. Normalises DER identity onto v1.0's vocabulary, and stops deriving the grid answers that v1.0 states outright.
122
+
123
+ ### Changed
124
+
125
+ - **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:
126
+ 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
127
+ 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
128
+ 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
129
+ arriving unplanned during a firmware upgrade a user did not choose the timing of.
130
+ - **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.
131
+
132
+ ### Added
133
+
134
+ - **`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
135
+ 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
136
+ `info/serial-number` rather than the Homie device id, which the proxy model warns is not stable across a proxy-to-native transition.
137
+
138
+ ### Fixed
139
+
140
+ - **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
141
+ 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
142
+ 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
143
+ 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
144
+ 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.
145
+ - **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
146
+ which package is missing and keeps the parser it has. Previously `SpanPanelAdapterMissingError` escaped a fire-and-forget task as a bare traceback.
147
+ - **`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.
148
+ 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
149
+ 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
150
+ guessed, because v1.0 names the forming device and its class is recoverable from the tree.
151
+ - **`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
152
+ 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.
153
+ - **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
154
+ have orphaned and reappeared as a duplicate. Both are the Drive's serial now, which is what real flat firmware keys by.
155
+
7
156
  ## [3.0.0b2] - 08/2026
8
157
 
9
158
  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,3 +1,22 @@
1
+ Metadata-Version: 2.5
2
+ Name: span-panel-api
3
+ Version: 3.0.0b6
4
+ Summary: A client library for SPAN Panel API
5
+ Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
6
+ Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
7
+ Author: SpanPanel
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: <4.0,>=3.10
11
+ Requires-Dist: httpx<1.0,>=0.28.1
12
+ Requires-Dist: paho-mqtt<3.0.0,>=2.0.0
13
+ Requires-Dist: pyyaml>=6.0.0
14
+ Provides-Extra: schema-0
15
+ Requires-Dist: span-panel-api-schema-0>=1.0.0b5; extra == 'schema-0'
16
+ Provides-Extra: schema-1
17
+ Requires-Dist: span-panel-api-schema-1>=0.1.0b6; extra == 'schema-1'
18
+ Description-Content-Type: text/markdown
19
+
1
20
  # SPAN Panel API
2
21
 
3
22
  [![GitHub Release](https://img.shields.io/github/v/release/SpanPanel/span-panel-api?style=flat-square)](https://github.com/SpanPanel/span-panel-api/releases)
@@ -120,7 +139,15 @@ async def main():
120
139
 
121
140
  # Get a point-in-time snapshot
122
141
  snapshot = await client.get_snapshot()
123
- print(f"Grid power: {snapshot.instant_grid_power_w}W")
142
+ # The upstream lugs' own meter. That is grid flow only where the lugs are
143
+ # the utility connection point; a BESS wired ahead of them, or a panel fed
144
+ # by another panel, makes it this panel's feed instead. `power_flow_grid`
145
+ # is the site-level figure in every topology.
146
+ if snapshot.lugs_at_service_entrance:
147
+ print(f"Grid power: {snapshot.instant_grid_power_w}W")
148
+ else:
149
+ print(f"Panel feed: {snapshot.instant_grid_power_w}W")
150
+ print(f"Grid power: {snapshot.power_flow_grid}W")
124
151
  print(f"Firmware: {snapshot.firmware_version}")
125
152
  print(f"Circuits: {len(snapshot.circuits)}")
126
153
 
@@ -377,6 +404,29 @@ The `PanelCapability` flag enum advertises transport features at runtime:
377
404
  | `CIRCUIT_CONTROL` | Can set relay state and shed priority |
378
405
  | `BATTERY_SOE` | Battery state-of-energy available |
379
406
 
407
+ ## Reference Payloads
408
+
409
+ Captures of what a panel actually serves, shipped as package data so a consumer can check its own assumptions against real bytes without vendoring a copy that silently goes stale:
410
+
411
+ ```python
412
+ from span_panel_api.reference_payloads import homie_schema, homie_schema_types
413
+
414
+ document = homie_schema() # the captured GET /api/v2/homie/schema response
415
+ types = homie_schema_types() # its `types` map, typed as HomieSchemaTypes
416
+ ```
417
+
418
+ `homie_schema_types()` returns exactly what `span_panel_api_schema_0.field_metadata.build_field_metadata` accepts, so building real adapter metadata to compare against is two lines and no file handling.
419
+
420
+ The parent/child device tree is the schema_1 counterpart and ships from that adapter, with the parser that can interpret it:
421
+
422
+ ```python
423
+ from span_panel_api_schema_1.reference_payloads import devices_from_tree, parent_child_tree
424
+
425
+ devices = devices_from_tree(parent_child_tree())
426
+ ```
427
+
428
+ Each payload carries the version of the release it shipped in. Pin a version and you read the bytes that version was written against.
429
+
380
430
  ## Project Structure
381
431
 
382
432
  ```text
@@ -390,6 +440,7 @@ src/span_panel_api/
390
440
  ├── models.py # Snapshot dataclasses (panel, circuit, battery, PV)
391
441
  ├── phase_validation.py # Electrical phase utilities
392
442
  ├── protocol.py # PEP 544 protocols + PanelCapability flags
443
+ ├── reference_payloads/ # Captured wire payloads shipped as package data
393
444
  └── mqtt/
394
445
  ├── __init__.py
395
446
  ├── accumulator.py # HomiePropertyAccumulator (Homie v5 protocol layer)
@@ -1,18 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: span-panel-api
3
- Version: 3.0.0b2
4
- Summary: A client library for SPAN Panel API
5
- Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
6
- Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
7
- Author: SpanPanel
8
- License-Expression: MIT
9
- License-File: LICENSE
10
- Requires-Python: <4.0,>=3.10
11
- Requires-Dist: httpx>=0.28.1
12
- Requires-Dist: paho-mqtt<3.0.0,>=2.0.0
13
- Requires-Dist: pyyaml>=6.0.0
14
- Description-Content-Type: text/markdown
15
-
16
1
  # SPAN Panel API
17
2
 
18
3
  [![GitHub Release](https://img.shields.io/github/v/release/SpanPanel/span-panel-api?style=flat-square)](https://github.com/SpanPanel/span-panel-api/releases)
@@ -135,7 +120,15 @@ async def main():
135
120
 
136
121
  # Get a point-in-time snapshot
137
122
  snapshot = await client.get_snapshot()
138
- print(f"Grid power: {snapshot.instant_grid_power_w}W")
123
+ # The upstream lugs' own meter. That is grid flow only where the lugs are
124
+ # the utility connection point; a BESS wired ahead of them, or a panel fed
125
+ # by another panel, makes it this panel's feed instead. `power_flow_grid`
126
+ # is the site-level figure in every topology.
127
+ if snapshot.lugs_at_service_entrance:
128
+ print(f"Grid power: {snapshot.instant_grid_power_w}W")
129
+ else:
130
+ print(f"Panel feed: {snapshot.instant_grid_power_w}W")
131
+ print(f"Grid power: {snapshot.power_flow_grid}W")
139
132
  print(f"Firmware: {snapshot.firmware_version}")
140
133
  print(f"Circuits: {len(snapshot.circuits)}")
141
134
 
@@ -392,6 +385,29 @@ The `PanelCapability` flag enum advertises transport features at runtime:
392
385
  | `CIRCUIT_CONTROL` | Can set relay state and shed priority |
393
386
  | `BATTERY_SOE` | Battery state-of-energy available |
394
387
 
388
+ ## Reference Payloads
389
+
390
+ Captures of what a panel actually serves, shipped as package data so a consumer can check its own assumptions against real bytes without vendoring a copy that silently goes stale:
391
+
392
+ ```python
393
+ from span_panel_api.reference_payloads import homie_schema, homie_schema_types
394
+
395
+ document = homie_schema() # the captured GET /api/v2/homie/schema response
396
+ types = homie_schema_types() # its `types` map, typed as HomieSchemaTypes
397
+ ```
398
+
399
+ `homie_schema_types()` returns exactly what `span_panel_api_schema_0.field_metadata.build_field_metadata` accepts, so building real adapter metadata to compare against is two lines and no file handling.
400
+
401
+ The parent/child device tree is the schema_1 counterpart and ships from that adapter, with the parser that can interpret it:
402
+
403
+ ```python
404
+ from span_panel_api_schema_1.reference_payloads import devices_from_tree, parent_child_tree
405
+
406
+ devices = devices_from_tree(parent_child_tree())
407
+ ```
408
+
409
+ Each payload carries the version of the release it shipped in. Pin a version and you read the bytes that version was written against.
410
+
395
411
  ## Project Structure
396
412
 
397
413
  ```text
@@ -405,6 +421,7 @@ src/span_panel_api/
405
421
  ├── models.py # Snapshot dataclasses (panel, circuit, battery, PV)
406
422
  ├── phase_validation.py # Electrical phase utilities
407
423
  ├── protocol.py # PEP 544 protocols + PanelCapability flags
424
+ ├── reference_payloads/ # Captured wire payloads shipped as package data
408
425
  └── mqtt/
409
426
  ├── __init__.py
410
427
  ├── accumulator.py # HomiePropertyAccumulator (Homie v5 protocol layer)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api"
3
- version = "3.0.0b2"
3
+ version = "3.0.0b6"
4
4
  description = "A client library for SPAN Panel API"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -10,18 +10,34 @@ license = "MIT"
10
10
  license-files = ["LICENSE"]
11
11
  requires-python = ">=3.10,<4.0"
12
12
  dependencies = [
13
- "httpx>=0.28.1",
13
+ # Bounded, and the bound is load-bearing. httpx 1.0 is an API rewrite that
14
+ # removes `AsyncClient` -- 1.0.dev1..dev4 are on PyPI now, and every
15
+ # distribution of this library is a prerelease, so `pip install --pre`, the
16
+ # verb RELEASE.md itself prescribes, resolves them. `paho-mqtt` has been
17
+ # bounded from the start; this was the one unbounded runtime dependency, and
18
+ # a ceiling cannot be added to a version already published.
19
+ "httpx>=0.28.1,<1.0",
14
20
  "paho-mqtt>=2.0.0,<3.0.0",
15
21
  "pyyaml>=6.0.0",
16
22
  ]
17
23
 
24
+ [project.optional-dependencies]
25
+ # Not runtime dependencies: this distribution still registers no adapter and
26
+ # imports none, and `scripts/verify_adapterless_install.py` holds that line.
27
+ # These exist so `pip install -U --pre "span-panel-api[schema-0,schema-1]"` has a
28
+ # correct upgrade path, because the dependency arrow runs the other way -- an
29
+ # adapter floors on the bootstrap, the bootstrap requires no adapter -- so
30
+ # upgrading the bootstrap alone leaves stale adapter wheels that
31
+ # `_derive_required_members` then rejects at discovery, with pip reporting
32
+ # success. An extra is the only thing pip can act on, and extras cannot be added
33
+ # to a version after it is published.
34
+ schema-0 = ["span-panel-api-schema-0>=1.0.0b5"]
35
+ schema-1 = ["span-panel-api-schema-1>=0.1.0b6"]
36
+
18
37
  [project.urls]
19
38
  Homepage = "https://github.com/SpanPanel/span-panel-api"
20
39
  Issues = "https://github.com/SpanPanel/span-panel-api/issues"
21
40
 
22
- [project.scripts]
23
- format-markdown = "scripts.format_markdown:main"
24
-
25
41
  # No [project.entry-points."span_panel_api.schema_adapters"] block here, and that
26
42
  # absence is the point of Phase 1: this distribution registers no adapter and
27
43
  # imports none. Adapters are separate distributions that register themselves —
@@ -45,7 +61,12 @@ dev = [
45
61
  "mypy",
46
62
  "pylint",
47
63
  "radon",
48
- "twine",
64
+ # 7.0 or newer: hatchling emits `Metadata-Version: 2.5` and twine 6.2 rejects
65
+ # it as invalid. The build backend is resolved fresh at build time from an
66
+ # unpinned `[build-system] requires`, so the metadata version moves without
67
+ # anything in this repository changing -- which is how a green CI turned red
68
+ # on a commit that touched two changelogs and a dependency floor.
69
+ "twine>=7.0",
49
70
  "vulture>=2.14",
50
71
  "types-pyyaml>=6.0.12.20250915",
51
72
  "coverage",
@@ -67,7 +88,29 @@ span-panel-api-schema-0 = { workspace = true }
67
88
  span-panel-api-schema-1 = { workspace = true }
68
89
 
69
90
  [tool.hatch.build.targets.wheel]
70
- packages = ["src/span_panel_api", "scripts"]
91
+ # `scripts/` is deliberately absent. Shipping it put `scripts/__init__.py` at the
92
+ # top level of every consumer's site-packages, so an unrelated `import scripts`
93
+ # in a Home Assistant venv resolved to this distribution, and it installed a
94
+ # markdown formatter as a console script for every user. It is a dev tool;
95
+ # `scripts/format.sh` runs it by path.
96
+ packages = ["src/span_panel_api"]
97
+
98
+ [tool.hatch.build.targets.sdist]
99
+ # Explicit, because the default swept the whole tree: the root sdist contained
100
+ # packages/schema-0 and packages/schema-1 in full, contradicting the one
101
+ # invariant this distribution is built around -- that it registers no adapter and
102
+ # imports none. Anyone auditing the bootstrap sdist found both parsers inside it.
103
+ # Anchored with a leading slash: an unanchored "README.md" is a glob that matches
104
+ # at any depth, which pulled each adapter's own README, CHANGELOG and pyproject
105
+ # back in and left the bootstrap sdist still naming both parsers.
106
+ include = [
107
+ "/src/span_panel_api",
108
+ "/tests",
109
+ "/README.md",
110
+ "/CHANGELOG.md",
111
+ "/LICENSE",
112
+ "/pyproject.toml",
113
+ ]
71
114
 
72
115
  [tool.ruff]
73
116
  line-length = 125
@@ -215,6 +258,10 @@ disable = [
215
258
  "missing-class-docstring",
216
259
  "missing-function-docstring",
217
260
  "too-few-public-methods",
261
+ # The transport implements four protocols, so its public surface is set by
262
+ # how many the composition asks for rather than by anything a split would
263
+ # improve. Every sibling in this family is already off for the same reason.
264
+ "too-many-public-methods",
218
265
  "too-many-arguments",
219
266
  "too-many-instance-attributes",
220
267
  "too-many-locals",
@@ -31,16 +31,25 @@ from .exceptions import (
31
31
  )
32
32
  from .factory import create_span_client
33
33
  from .models import (
34
+ ADOPTION_IDENTITY_NODE,
35
+ ADOPTION_TOPOLOGY_NODE,
36
+ DISCOVERY_NAMESPACE,
37
+ AdoptedDevice,
38
+ AdoptedProperty,
39
+ DiscoveredMetadata,
34
40
  FieldMetadata,
35
41
  HomieSchemaTypes,
36
42
  SpanBatterySnapshot,
37
43
  SpanCircuitSnapshot,
38
44
  SpanEvseSnapshot,
45
+ SpanMidSnapshot,
39
46
  SpanPanelSnapshot,
47
+ SpanPcsSnapshot,
40
48
  SpanPVSnapshot,
41
49
  V2AuthResponse,
42
50
  V2HomieSchema,
43
51
  V2StatusInfo,
52
+ is_discovery_path,
44
53
  )
45
54
  from .mqtt import MqttClientConfig, SpanMqttClient
46
55
  from .phase_validation import (
@@ -52,7 +61,9 @@ from .phase_validation import (
52
61
  validate_solar_tabs,
53
62
  )
54
63
  from .protocol import (
64
+ AdoptedControlProtocol,
55
65
  CircuitControlProtocol,
66
+ EvseControlProtocol,
56
67
  PanelCapability,
57
68
  PanelControlProtocol,
58
69
  SpanPanelClientProtocol,
@@ -64,6 +75,17 @@ __version__ = _pkg_version("span-panel-api")
64
75
  __all__ = [ # noqa: RUF022
65
76
  # Protocols
66
77
  "CircuitControlProtocol",
78
+ # Added 2026-08-19: the charge-current ceiling on a commissioned EV charger,
79
+ # the first settable property outside the panel and its circuits. Purely
80
+ # additive -- a consumer that never asks for it is unaffected, and flat
81
+ # firmware publishes no such property, so the flat adapter answers None and
82
+ # the transport refuses.
83
+ "EvseControlProtocol",
84
+ # Added 2026-08-20 with device-scoped adoption: the first control whose
85
+ # subject this library does not understand. Additive, and authorised by the
86
+ # snapshot rather than by its arguments -- a device the adapter models
87
+ # produces no AdoptedDevice and so cannot be addressed through it.
88
+ "AdoptedControlProtocol",
67
89
  "PanelCapability",
68
90
  "PanelControlProtocol",
69
91
  "SpanPanelClientProtocol",
@@ -71,12 +93,28 @@ __all__ = [ # noqa: RUF022
71
93
  # Metadata
72
94
  "FieldMetadata",
73
95
  "HomieSchemaTypes",
96
+ # Added 2026-08-20: runtime discovery. Purely additive -- an adapter that
97
+ # emits no discovered rows is indistinguishable from one built before the
98
+ # namespace existed, and a consumer that never partitions on the namespace
99
+ # sees exactly the curated rows it saw before.
100
+ "DISCOVERY_NAMESPACE",
101
+ "DiscoveredMetadata",
102
+ "is_discovery_path",
103
+ # Added 2026-08-20: device-scoped adoption. Additive in the same way --
104
+ # `SpanPanelSnapshot.adopted_devices` defaults empty, so an adapter that
105
+ # adopts nothing and a consumer that reads the field are both unaffected.
106
+ "ADOPTION_IDENTITY_NODE",
107
+ "ADOPTION_TOPOLOGY_NODE",
108
+ "AdoptedDevice",
109
+ "AdoptedProperty",
74
110
  # Snapshots
75
111
  "SpanBatterySnapshot",
76
112
  "SpanCircuitSnapshot",
77
113
  "SpanEvseSnapshot",
114
+ "SpanMidSnapshot",
78
115
  "SpanPVSnapshot",
79
116
  "SpanPanelSnapshot",
117
+ "SpanPcsSnapshot",
80
118
  # Factory
81
119
  "create_span_client",
82
120
  # Detection