span-panel-api 2.6.4__tar.gz → 3.0.0__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 (129) hide show
  1. span_panel_api-3.0.0/.gitignore +47 -0
  2. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/CHANGELOG.md +140 -0
  3. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/PKG-INFO +151 -38
  4. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/README.md +143 -34
  5. span_panel_api-3.0.0/pyproject.toml +327 -0
  6. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/__init__.py +47 -3
  7. span_panel_api-3.0.0/src/span_panel_api/adapters.py +232 -0
  8. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/auth.py +117 -19
  9. span_panel_api-3.0.0/src/span_panel_api/dispatch.py +74 -0
  10. span_panel_api-3.0.0/src/span_panel_api/exceptions.py +111 -0
  11. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/factory.py +42 -4
  12. span_panel_api-3.0.0/src/span_panel_api/models.py +1122 -0
  13. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/mqtt/__init__.py +5 -6
  14. span_panel_api-3.0.0/src/span_panel_api/mqtt/client.py +1128 -0
  15. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/mqtt/connection.py +14 -1
  16. span_panel_api-3.0.0/src/span_panel_api/mqtt/const.py +30 -0
  17. span_panel_api-3.0.0/src/span_panel_api/protocol.py +224 -0
  18. {span_panel_api-2.6.4/tests/fixtures/v2 → span_panel_api-3.0.0/src/span_panel_api/reference_payloads}/README.md +7 -11
  19. span_panel_api-3.0.0/src/span_panel_api/reference_payloads/__init__.py +66 -0
  20. span_panel_api-3.0.0/src/span_panel_api/schema_drift.py +65 -0
  21. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/conftest.py +67 -11
  22. span_panel_api-3.0.0/tests/fixtures/flat_wire.json +563 -0
  23. span_panel_api-3.0.0/tests/fixtures/panelbench_unvalued_by_both.json +125 -0
  24. span_panel_api-3.0.0/tests/fixtures/v2/README.md +14 -0
  25. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_accumulator.py +2 -2
  26. span_panel_api-3.0.0/tests/test_adapters_discovery.py +398 -0
  27. span_panel_api-3.0.0/tests/test_adopted_control.py +188 -0
  28. span_panel_api-3.0.0/tests/test_adoption.py +414 -0
  29. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_auth_and_homie_helpers.py +98 -5
  30. span_panel_api-3.0.0/tests/test_catalog_divergence.py +685 -0
  31. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_detection_auth.py +156 -7
  32. span_panel_api-3.0.0/tests/test_factory_dispatch.py +310 -0
  33. span_panel_api-3.0.0/tests/test_field_metadata.py +726 -0
  34. span_panel_api-3.0.0/tests/test_live_flat_differential.py +166 -0
  35. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_mqtt_client_connection.py +125 -17
  36. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_mqtt_connect_flow.py +90 -19
  37. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_mqtt_homie.py +31 -33
  38. span_panel_api-3.0.0/tests/test_packaging.py +69 -0
  39. span_panel_api-3.0.0/tests/test_protocol_conformance.py +119 -0
  40. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_protocol_models.py +0 -1
  41. span_panel_api-3.0.0/tests/test_public_api_unchanged.py +124 -0
  42. span_panel_api-3.0.0/tests/test_redispatch_on_reconnect.py +482 -0
  43. span_panel_api-3.0.0/tests/test_reference_tree_values.py +140 -0
  44. span_panel_api-3.0.0/tests/test_schema_generation_cross_check.py +119 -0
  45. span_panel_api-3.0.0/tests/test_schema_migration_delta.py +579 -0
  46. span_panel_api-3.0.0/tests/test_schema_one_adapter.py +472 -0
  47. span_panel_api-3.0.0/tests/test_schema_one_against_simulator.py +260 -0
  48. span_panel_api-3.0.0/tests/test_schema_one_charge_limit.py +540 -0
  49. span_panel_api-3.0.0/tests/test_schema_one_circuits.py +183 -0
  50. span_panel_api-3.0.0/tests/test_schema_one_conformance.py +726 -0
  51. span_panel_api-3.0.0/tests/test_schema_one_connection_health.py +371 -0
  52. span_panel_api-3.0.0/tests/test_schema_one_devices.py +488 -0
  53. span_panel_api-3.0.0/tests/test_schema_one_discovery.py +553 -0
  54. span_panel_api-3.0.0/tests/test_schema_one_extension.py +303 -0
  55. span_panel_api-3.0.0/tests/test_schema_one_panel.py +614 -0
  56. span_panel_api-3.0.0/tests/test_schema_one_pcs.py +522 -0
  57. span_panel_api-3.0.0/tests/test_schema_one_service_entrance.py +196 -0
  58. span_panel_api-3.0.0/tests/test_schema_one_shed_forecast.py +289 -0
  59. span_panel_api-3.0.0/tests/test_schema_one_snapshot.py +140 -0
  60. span_panel_api-3.0.0/tests/test_schema_one_transport.py +233 -0
  61. span_panel_api-3.0.0/tests/test_schema_provenance.py +177 -0
  62. span_panel_api-3.0.0/tests/test_schema_zero_adapter.py +87 -0
  63. span_panel_api-3.0.0/tests/test_shared_http_client.py +121 -0
  64. span_panel_api-3.0.0/tests/test_ssl_context.py +234 -0
  65. span_panel_api-2.6.4/.codefactor +0 -29
  66. span_panel_api-2.6.4/.codefactor.yml +0 -18
  67. span_panel_api-2.6.4/.deps-installed +0 -0
  68. span_panel_api-2.6.4/.github/ISSUE_TEMPLATE/bug_report.md +0 -37
  69. span_panel_api-2.6.4/.github/ISSUE_TEMPLATE/feature_request.md +0 -31
  70. span_panel_api-2.6.4/.github/dependabot.yml +0 -62
  71. span_panel_api-2.6.4/.github/workflows/ci.yml +0 -101
  72. span_panel_api-2.6.4/.github/workflows/dependabot-auto-approve.yml +0 -38
  73. span_panel_api-2.6.4/.github/workflows/dependabot-auto-merge.yml +0 -56
  74. span_panel_api-2.6.4/.github/workflows/release.yml +0 -42
  75. span_panel_api-2.6.4/.gitignore +0 -36
  76. span_panel_api-2.6.4/.markdownlint-cli2.jsonc +0 -49
  77. span_panel_api-2.6.4/.markdownlint.json +0 -11
  78. span_panel_api-2.6.4/.pre-commit-config.yaml +0 -136
  79. span_panel_api-2.6.4/.prettierrc.json +0 -25
  80. span_panel_api-2.6.4/.vscode/extensions.json +0 -5
  81. span_panel_api-2.6.4/.vscode/tasks.json +0 -131
  82. span_panel_api-2.6.4/DEVELOPMENT.md +0 -79
  83. span_panel_api-2.6.4/SECURITY.md +0 -11
  84. span_panel_api-2.6.4/conftest.py +0 -18
  85. span_panel_api-2.6.4/developer_attribute_readme.md +0 -276
  86. span_panel_api-2.6.4/openapi.json +0 -1621
  87. span_panel_api-2.6.4/pyproject.toml +0 -213
  88. span_panel_api-2.6.4/pytest.ini +0 -2
  89. span_panel_api-2.6.4/pytest_output.log +0 -11
  90. span_panel_api-2.6.4/scripts/__init__.py +0 -1
  91. span_panel_api-2.6.4/scripts/coverage.py +0 -118
  92. span_panel_api-2.6.4/scripts/format.sh +0 -25
  93. span_panel_api-2.6.4/scripts/format_markdown.py +0 -39
  94. span_panel_api-2.6.4/scripts/test_live_auth.py +0 -311
  95. span_panel_api-2.6.4/setup-hooks.sh +0 -31
  96. span_panel_api-2.6.4/src/span_panel_api/exceptions.py +0 -42
  97. span_panel_api-2.6.4/src/span_panel_api/models.py +0 -231
  98. span_panel_api-2.6.4/src/span_panel_api/mqtt/accumulator.py +0 -273
  99. span_panel_api-2.6.4/src/span_panel_api/mqtt/client.py +0 -489
  100. span_panel_api-2.6.4/src/span_panel_api/mqtt/const.py +0 -71
  101. span_panel_api-2.6.4/src/span_panel_api/mqtt/field_metadata.py +0 -229
  102. span_panel_api-2.6.4/src/span_panel_api/mqtt/homie.py +0 -635
  103. span_panel_api-2.6.4/src/span_panel_api/protocol.py +0 -79
  104. span_panel_api-2.6.4/tests/test_field_metadata.py +0 -280
  105. span_panel_api-2.6.4/tests/test_protocol_conformance.py +0 -48
  106. span_panel_api-2.6.4/uv.lock +0 -1518
  107. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/LICENSE +0 -0
  108. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/_http.py +0 -0
  109. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/const.py +0 -0
  110. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/detection.py +0 -0
  111. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/mqtt/async_client.py +0 -0
  112. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/mqtt/models.py +0 -0
  113. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/phase_validation.py +0 -0
  114. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/src/span_panel_api/py.typed +0 -0
  115. {span_panel_api-2.6.4/tests/fixtures/v2 → span_panel_api-3.0.0/src/span_panel_api/reference_payloads}/homie_schema.json +0 -0
  116. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  117. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  118. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  119. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/fixtures/v2/status.json +0 -0
  120. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
  121. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/simulation_fixtures/panel.response.txt +0 -0
  122. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/simulation_fixtures/soe.response.txt +0 -0
  123. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/simulation_fixtures/status.response.txt +0 -0
  124. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_async_mqtt_client.py +0 -0
  125. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_exceptions.py +0 -0
  126. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_mqtt_bridge.py +0 -0
  127. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_mqtt_debounce.py +0 -0
  128. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_phase_validation_configs.py +0 -0
  129. {span_panel_api-2.6.4 → span_panel_api-3.0.0}/tests/test_phase_validation_errors.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,146 @@ 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
+ Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the **last public release**, never against the beta before it. What one
8
+ beta corrected in an earlier beta does not appear at all: from the point of view of somebody upgrading between released versions, it never happened.
9
+
10
+ ## [3.0.0]
11
+
12
+ `span-panel-api` becomes a transport and a dispatcher that contains **no parser**. Wire formats ship as separate distributions and register themselves through the `span_panel_api.schema_adapters` entry-point group, so support for a new panel schema arrives
13
+ by installing a package rather than by upgrading the transport.
14
+
15
+ ### Removed
16
+
17
+ - **BREAKING: `span-panel-api` no longer contains a parser.** Installing it alone gives a client that connects and then raises `SpanPanelAdapterMissingError`. A parser is an install:
18
+
19
+ ```console
20
+ # flat-schema panels, firmware r202603-r202627
21
+ pip install "span-panel-api[schema-0]"
22
+
23
+ # parent/child panels, firmware r202633+
24
+ pip install "span-panel-api[schema-1]"
25
+ ```
26
+
27
+ The adapter distributions can equally be named directly; the extras exist because the dependency arrow runs the other way — an adapter declares a floor on the bootstrap, the bootstrap requires no adapter — so upgrading the bootstrap alone would otherwise
28
+ leave a stale adapter wheel that discovery then rejects, with pip reporting success. The bootstrap never imports an adapter, and supporting a new panel schema on an existing install is an install rather than an upgrade.
29
+
30
+ - **BREAKING: `HomieLifecycle`, `HomiePropertyAccumulator` and `HomieDeviceConsumer` are no longer exported** from `span_panel_api` or `span_panel_api.mqtt`. All three are flat-schema-specific rather than Homie-convention-level: the accumulator filters
31
+ every topic against a single device's prefix and stores `node → prop`, which drops nearly every message under the parent/child model; `HomieLifecycle`'s members are not Homie 5 `$state` values but a consumer-side progression encoding "one description
32
+ received ⇒ ready", which is the flat readiness model. They now live in `span_panel_api_schema_0`.
33
+ - **Removed dead constants** `DEVICE_TOPIC_FMT`, `STATE_TOPIC_FMT`, `DESCRIPTION_TOPIC_FMT`, `PROPERTY_TOPIC_FMT` (unreferenced) and `TYPE_PCS` (a real schema type this library does not consume).
34
+
35
+ ### Changed
36
+
37
+ - **BREAKING — DER identity speaks the parent/child 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:
38
+ it puts the SKU in `bess/model` and in `evse/part-number`, the same concept under two names, and gives PV neither. Mirroring that would have permanently encoded flat's irregularity in the snapshot, so `schema_0` translates flat into the normalised shape
39
+ instead. Measured: every EVSE identity field reads identically on both adapters, so for that device class identity stops being a migration delta at all. **`battery.model` changes value for existing flat users at this upgrade** — it gains the designation
40
+ where it carried the SKU. That is the deliberate trade: a change scheduled in a library release beats the same change arriving unplanned during a firmware upgrade a user did not choose the timing of.
41
+ - **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.
42
+ - **Dispatch refuses an unreadable `data-model-version` instead of assuming flat.** Absence still means the flat schema — that is a real signal, since the property was introduced by the firmware that introduced parent/child. A value whose major _can_ be
43
+ read but whose form is non-canonical (`1`, `1.0-beta`) dispatches on that major and logs the deviation. A value with no extractable major raises `SpanPanelSchemaVersionError`. Previously all three fell through to the flat parser, which does not fail — it
44
+ produces plausible but wrong power and energy figures.
45
+ - **`get_homie_schema()` tells "not ready yet" apart from "will not fix itself".** Any 5xx raises `SpanPanelServerError`, a transport failure raises `SpanPanelConnectionError`, and a `200` carrying a truncated or empty body raises `SpanPanelServerError`
46
+ rather than surfacing as a parse error. A booting panel brings its network stack and reverse proxy up before the application behind them, so it answers rather than refuses; the distinction is what lets a caller retry that and not retry a 4xx.
47
+
48
+ ### Added
49
+
50
+ #### Adapter architecture
51
+
52
+ - **The `SchemaAdapter` protocol, and `ADAPTER_CONTRACT` alongside it.** Member presence is not the whole contract — a Protocol cannot express signatures at runtime, so an adapter carrying every required name and the wrong `__init__` arity would pass
53
+ discovery and fail much later inside the transport, as a bare `TypeError` about an argument count. Every adapter declares `ADAPTER_CONTRACT` as a **literal** and discovery rejects anything that does not match this package's `ADAPTER_CONTRACT_VERSION`; a
54
+ value read from the installed bootstrap would agree with every bootstrap, which is the disagreement being looked for. The required-member set is derived from every public member the protocol declares, not only the callable ones.
55
+ - **`installed_adapter_keys()` and `SpanMqttClient.installed_adapters`.** Enumeration reads distribution metadata only; an adapter is imported the first time a panel asks for that key. A flat panel therefore never imports `schema_1`, and with it never
56
+ imports the eBus SDK or jsonschema, for a parser it would not call. The async paths run both in a thread, and resolution stays cached per key, which is what keeps the synchronous pre-rebuild callback free of I/O.
57
+ - **`resolve_adapter(key, reason)`** — the single place a missing adapter becomes a named error, used by both dispatch and the transport's default path.
58
+ - **`span_panel_api.dispatch.select_adapter_key`**, so the transport can dispatch without importing the factory. `adapters.py` answers "what is installed"; `dispatch.py` answers "what does this panel need".
59
+ - **`SpanPanelAdapterMissingError`, `SpanPanelSchemaVersionError` and `SpanPanelAdapterIncompatibleError`**, all exported from the top-level package. The three are separate because the remedy differs: missing means install something, a schema version no
60
+ adapter can even be named for means there is nothing to install yet, and incompatible means installing more cannot help. Reporting the third as the first sends someone to install a package they already have. Discovery only _logs_ a rejection, so one
61
+ unusable third-party adapter cannot take down a panel whose own adapter is fine; the error surfaces only when the rejected adapter turns out to be the one required.
62
+ - **`SpanMqttClient(adapter_factory=...)` is optional.** When omitted the parser is resolved through entry-point discovery at `_build_adapter()`. Resolution is lazy by design: constructing a client must not require an adapter to be installed, only building
63
+ a parser must. Dispatch happens wherever a parser is built, so a directly constructed client dispatches exactly as the factory path does.
64
+ - **`V2HomieSchema.data_model_version`**, carrying the `dataModelVersion` field and `None` when the panel omits it. Absence is the flat signal and stays distinct from an empty string.
65
+
66
+ #### Surviving a firmware upgrade
67
+
68
+ - **A panel that changes schema generation mid-life is redispatched rather than reloaded.** The schema is refetched over REST and the parser swapped in place, so an install that upgrades from flat to parent/child keeps running. The new adapter is resolved
69
+ **before** any state is touched, so a flat-only install that meets a parent/child panel logs which package is missing and keeps the parser it has instead of raising into a background task.
70
+ - **The wait for a panel to finish rebooting does not give up.** Any bound here is sized against a reboot somebody measured, and the next reboot is not that reboot — a live firmware upgrade has been observed taking four minutes from MQTT dropping to the
71
+ broker returning, still answering `502` at that point. Giving up has nothing to recommend it: the only things that start another attempt are the reconnect edge and the panel republishing its data-model version, and a panel that finishes booting after the
72
+ wait expired produces neither, so running out of attempts means stranded until somebody reloads by hand.
73
+ - **The retry interval settles at thirty seconds rather than growing.** Backing off without a ceiling would mean a panel that took a while to return was then ignored for longer than it took. The gap goes 1, 2, 4, 8, 16, 30 and stays there, so once your
74
+ panel is answering it is noticed within half a minute however long the wait has already run. Waiting costs nothing you were relying on — energy sensors hold their last reading through an outage on their own grace period, which is untouched by this — and
75
+ what is left is one request every thirty seconds to a device on your own network.
76
+ - **Nothing escapes the redispatch task.** An unexpected failure there used to surface as a bare `Task exception was never retrieved` while the parser silently stayed on the old generation. It is logged at ERROR naming the consequence and the remedy,
77
+ because a reload is the user's only move and nothing else was going to tell them.
78
+
79
+ #### Injected HTTP client on the runtime path
80
+
81
+ - **`SpanMqttClient` accepts an `httpx_client`, and so does `create_span_client`.** Four config-flow-facing entry points already took an injected client; the runtime path was the one that did not, so every schema read built a throwaway — including the
82
+ retry loop that runs during a firmware upgrade, which built one per attempt at exactly the moment the panel was mid-reboot. Optional and defaulted, so nothing outside Home Assistant changes. The ownership rule is the one the existing entry points already
83
+ state: a client handed in is never closed here, and its timeouts, limits and headers are the caller's, which is why the per-call `timeout` defaults are ignored when one is given.
84
+
85
+ #### Reference payloads shipped in the wheel
86
+
87
+ - **`span_panel_api.reference_payloads`, shipping `homie_schema.json` as package data.** The captured `GET /api/v2/homie/schema` response is reached by `homie_schema()` and `homie_schema_types()` rather than by path. It was already being consumed outside
88
+ this repository — the Home Assistant integration checks the field paths it declares against what an adapter can actually produce — by vendoring a byte copy with a README explaining where the copy came from. A copy has no version: it goes stale in
89
+ 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 came with. `homie_schema_types()` returns `HomieSchemaTypes`, precisely what
90
+ `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. The parent/child device tree is the other half and ships from `span-panel-api-schema-1`, with the
91
+ parser that can interpret it.
92
+
93
+ #### New snapshot surface
94
+
95
+ Everything below is additive. Each field is `None` or empty on a panel that publishes no such thing, and no flat panel publishes any of it unless stated.
96
+
97
+ - **`SpanMidSnapshot` and `SpanPanelSnapshot.mid`.** The parent/child model puts the `grid` capability on a Microgrid Interconnect Device rather than on the enclosure, so islanding state, grid state and the grid-forming entity live there. Presence is
98
+ `snapshot.mid is not None` rather than a sentinel field, and identity is `info/serial-number` rather than the Homie device id, which the proxy model warns is not stable across a proxy-to-native transition.
99
+ - **`dsm_state` and `current_run_config` are read from the MID.** Both are existing entities that would otherwise degrade to `UNKNOWN` on a parent/child panel: `schema_0` _derives_ them from a multi-signal heuristic, and the parent/child model states the
100
+ answer outright. 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 otherwise. A missing MID never reports on-grid — it
101
+ 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 guessed.
102
+ - **`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
103
+ 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.
104
+ - **`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
105
+ 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
106
+ 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
107
+ negation table. Defaults `True`, because flat firmware predates chaining and a flat panel's lugs really are its service entrance.
108
+ - **`SpanBatterySnapshot.power_w` and `SpanBatterySnapshot.communication_state`.** The battery device has always published `meter/active-power` and `status/communication-state` and neither reached a field, so a consumer could show the enclosure's
109
+ arbitrated `power_flow_battery` and nothing the BESS itself reports. `power_w` is **discharge-positive**: the enclosure meters the BESS the way it meters a circuit it feeds, so positive means power flowing _out of_ the battery, matching the eBus rule for
110
+ a device's own meter. The asymmetry with `panel.power_flow_battery` is deliberate — the enclosure's arbitrated figure is passed through untouched by both adapters and is charge-positive, so it reads negative for the same discharging battery that makes
111
+ `power_w` positive. The two describe the same physical power in opposite frames, and a consumer rendering both negates one of them. `communication_state` stays the published enum string (`OK`/`DEGRADED`/`LOST`/`UNKNOWN`) rather than collapsing to a bool,
112
+ because `DEGRADED` is neither `OK` nor `LOST`; it is deliberately not merged into `battery.connected`, which is the _enclosure's_ view of the same link.
113
+ - **`SpanEvseSnapshot.connected` and `SpanPVSnapshot.connected`.** `battery.connected` has carried the enclosure's view of the link to the BESS from the upstream lugs' `connection/fed-by-device-status`; the other half of the same capability — a circuit's
114
+ `connection/feeds-device-status` — reached nothing, so only one of a panel's three DER classes had a link-health field. `None` is the specification's "unknown" and is load-bearing: the enum is `OK,LOST,DEGRADED` with no `UNKNOWN` member, and a mixed-load
115
+ 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. `DEGRADED` collapses to `False`, because the question this field answers is whether the enclosure can talk
116
+ to the device. 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, and a charger mid-session over a lost link publishes `CHARGING` and `connected=False` at once.
117
+ - **Five `shed-forecast` 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 `shed_forecast_confidence`. The backup-planning numbers —
118
+ how long before my battery starts shedding circuits, how long before it is exhausted — were on the wire and stopped at the transport. All four times are `integer` minutes as the capability declares, parsed so that a publisher serialising a whole number
119
+ with a decimal point still resolves; `confidence` stays the raw `LOW`/`MEDIUM`/`HIGH` string, because it qualifies the four times rather than standing alone. `None` is load-bearing here too: zero minutes is a legitimate reading — shedding starts now — so
120
+ a defaulted zero would be indistinguishable from the worst forecast the capability can report.
121
+ - **`SpanPanelSnapshot.adopted_devices`, reporting a device type this library models nothing for rather than dropping it.** The schema is explicitly vendor-extensible, so an unmodelled device is an expected arrival rather than a hypothetical one; before
122
+ this it produced no field, no metadata row and no sign it was there. `AdoptedDevice` carries the device's identity and its readings. **The unit is a device, never a property**: a new property on a device already modelled is a curation task with a short
123
+ turnaround, and surfacing it automatically would spend a consumer's entity identity permanently on a shape a human would likely have chosen differently. An unmodelled _type_ is the opposite case — no curation is coming, so silence is the only
124
+ alternative. Extra instances of a modelled type are deliberately not adopted either: a second BESS is a multiplicity limit, not an unmodelled device.
125
+ - **`AdoptedDevice.parent` and `AdoptedDevice.proxied`**, carrying the proxy link a device declares. Carried rather than acted on — an adopted device is still registered under the enclosure — because a _proxied_ unmodelled device is a real shape that would
126
+ otherwise be flattened away unrecorded. The nesting is deliberately not built: proxied ids differ by design and consumers correlate by `info/serial-number` rather than by device id, and the tree model is being reshaped upstream, so the fields capture the
127
+ evidence and the topology waits.
128
+ - **`AdoptedProperty.set_topic`, `SpanMqttClient.set_adopted_property` and `AdoptedControlProtocol`**, so a settable property on an adopted device can be written and the write cannot reach anything else. The topic is populated only for a settable property
129
+ on a device `is_modelled` 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
130
+ carries, no topic is accepted from the caller, and a device this library models produces no `AdoptedDevice` to find. There is deliberately no translation and no bounds check on an adopted write — both exist on curated controls because this library knows
131
+ what those properties mean, and inventing a bound for somebody else's hardware would be inventing a fact. `AdoptedControlProtocol` lets a consumer ask `isinstance` before offering the control, exactly as it does for circuit, panel and EVSE control.
132
+ - **`SpanPanelSnapshot.extension_properties`, `ExtensionProperty` and `ExtensionSubject`**, so a vendor property on a device this library _already_ models reaches a consumer instead of stopping at diagnostics. Adoption covers the unmodelled-device half;
133
+ this covers the other one, where a new property on the BESS, a charger, a circuit or the panel would otherwise be a declaration with no value, visible only to a maintainer reading a diagnostics attachment. The subject names which modelled snapshot
134
+ subject a property hangs off — `battery`, `mid`, `pv`, `panel`, `lugs` with `upstream`/`downstream`, and `evse`/`circuit` with the instance key the snapshot's own maps use — so a consumer resolves the device with a lookup it already performs. What is
135
+ _not_ exposed is the field-level mapping: the subject is one value per device and cannot drift, while the wire-property-to-snapshot-field map is the adapter's internal business and exporting it would freeze it as API.
136
+ - **An extension property's value never reaches diagnostics, structurally.** `ExtensionProperty` is deliberately not a `FieldMetadata`, so it cannot enter the map `partition()` walks and has no path into a payload that leaves the machine. The discovery
137
+ rows keep flowing unchanged: the same property appears in both surfaces on purpose, joined by its `{node}/{property}` path — a declaration for the maintainer, a reading for the user. It is read-only by construction: it carries `settable` for curation
138
+ triage and no set topic, and there is no member a write path could be built from.
139
+
140
+ ### Fixed
141
+
142
+ - **A single HTTP 429 from the panel no longer aborts setup.** The panel rate-limits `GET /api/v2/certificate/ca` at roughly seven requests a second, and `download_ca_cert()` raised on any non-200 — so a reconnect storm, or simply a second client polling
143
+ the same panel, turned a transient condition into a hard failure that needed a manual reload. It now retries a 429 with exponential backoff, honouring `Retry-After` when the panel sends it and falling back to the backoff curve when the header is absent
144
+ or malformed. Non-429 responses still fail fast. `max_attempts` and `backoff_s` are parameters, so a caller can tune the behaviour or switch it off. Reported and fixed by [@brunocramos](https://github.com/brunocramos) in
145
+ [#148](https://github.com/SpanPanel/span-panel-api/pull/148).
146
+
7
147
  ## [2.6.4] - 05/2026
8
148
 
9
149
  ### Fixed
@@ -1,16 +1,20 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: span-panel-api
3
- Version: 2.6.4
3
+ Version: 3.0.0
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
7
7
  Author: SpanPanel
8
8
  License-Expression: MIT
9
9
  License-File: LICENSE
10
- Requires-Python: <4.0,>=3.10
11
- Requires-Dist: httpx>=0.28.1
10
+ Requires-Python: <4.0,>=3.14
11
+ Requires-Dist: httpx<1.0,>=0.28.1
12
12
  Requires-Dist: paho-mqtt<3.0.0,>=2.0.0
13
13
  Requires-Dist: pyyaml>=6.0.0
14
+ Provides-Extra: schema-0
15
+ Requires-Dist: span-panel-api-schema-0>=1.0.0; extra == 'schema-0'
16
+ Provides-Extra: schema-1
17
+ Requires-Dist: span-panel-api-schema-1>=1.0.0; extra == 'schema-1'
14
18
  Description-Content-Type: text/markdown
15
19
 
16
20
  # SPAN Panel API
@@ -38,10 +42,40 @@ A Python client library for the SPAN Panel v2 API, using MQTT/Homie for real-tim
38
42
 
39
43
  ## Installation
40
44
 
45
+ Two packages: the transport, and a parser for your panel's schema. `span-panel-api` contains **no parser** — installing it alone gives a client that connects and then raises `SpanPanelAdapterMissingError`.
46
+
41
47
  ```bash
42
- pip install span-panel-api
48
+ # flat schema, firmware r202603-r202627
49
+ pip install "span-panel-api[schema-0]"
50
+
51
+ # parent/child schema, firmware r202633+ (data-model-version 1.x)
52
+ pip install "span-panel-api[schema-1]"
53
+
54
+ # support either panel from one install
55
+ pip install "span-panel-api[schema-0,schema-1]"
43
56
  ```
44
57
 
58
+ The extras are the recommended spelling because they give `pip install -U` a correct upgrade path; naming `span-panel-api-schema-0` / `span-panel-api-schema-1` directly works too.
59
+
60
+ ### The parser is hot-loaded, not imported
61
+
62
+ `span-panel-api` never imports a parser. Each wire format is its own distribution, registering itself under the `span_panel_api.schema_adapters` entry-point group, and the transport reaches it by key at runtime:
63
+
64
+ 1. **Ask the panel first.** Before the broker is opened, the client fetches `GET /api/v2/homie/schema` over REST and reads `dataModelVersion`. Absence means the flat schema — a real signal, since the property arrived with the firmware that introduced
65
+ parent/child. A value whose major can be read but whose form is non-canonical (`1`, `1.0-beta`) dispatches on that major and logs the deviation; one with no extractable major raises `SpanPanelSchemaVersionError` rather than guessing.
66
+ 2. **Enumerate without importing.** `installed_adapter_keys()` reads distribution metadata only. Nothing is imported to find out what is installed, so a flat panel never pays for `span-panel-api-schema-1` — nor for the eBus SDK underneath it.
67
+ 3. **Resolve on demand, once.** The adapter for the selected key is imported the first time a panel asks for it, then cached. The async paths run enumeration and resolution in a thread, so neither blocks the event loop.
68
+ 4. **Verify the contract before trusting it.** Every adapter declares `ADAPTER_CONTRACT` as a literal, and discovery rejects any that does not match this package's `ADAPTER_CONTRACT_VERSION`. Member presence is not the whole contract — a Protocol cannot
69
+ express signatures at runtime — so this is what stops two packages built against different versions of each other failing much later as a bare `TypeError` inside the transport. A rejection is logged rather than raised, so one unusable third-party
70
+ adapter cannot take down a panel whose own adapter is fine.
71
+ 5. **Re-dispatch when the panel changes underneath you.** A panel that upgrades firmware from flat to parent/child mid-life drops MQTT, reboots and comes back on a new schema. The client refetches, resolves the new adapter **before** touching any state,
72
+ and swaps the parser in place — no reload. An install with no adapter for the new generation logs which package to install and keeps the parser it has.
73
+
74
+ Three errors keep the failure modes apart, because the remedy differs: `SpanPanelAdapterMissingError` (install something), `SpanPanelSchemaVersionError` (a schema no adapter can even be named for), and `SpanPanelAdapterIncompatibleError` (installing more
75
+ cannot help). All are exported from the top-level package.
76
+
77
+ The consequence worth planning around: **supporting a new panel schema is an install, not an upgrade.** The distributions version independently — see [RELEASE.md](RELEASE.md).
78
+
45
79
  ### Dependencies
46
80
 
47
81
  - `httpx` — v2 authentication and detection endpoints
@@ -52,10 +86,15 @@ pip install span-panel-api
52
86
 
53
87
  ### Transport
54
88
 
55
- The `SpanMqttClient` connects to the panel's MQTT broker (MQTTS or WebSocket) and subscribes to the Homie device tree. A two-layer architecture separates generic Homie v5 protocol handling from SPAN-specific interpretation:
89
+ The `SpanMqttClient` connects to the panel's MQTT broker (MQTTS or WebSocket) and subscribes to the Homie device tree. It owns the connection, the subscription and the dispatch decision and nothing else. Everything that knows what a topic _means_ lives
90
+ in the adapter for that panel's schema:
91
+
92
+ - **The transport** (this package) makes one wildcard subscription, routes messages, tracks connection state, publishes commands, and hands raw messages to whichever parser was resolved for this panel.
93
+ - **The parser** (`span-panel-api-schema-0` or `span-panel-api-schema-1`) accumulates properties, decides when the panel is ready to read, and builds typed `SpanPanelSnapshot` dataclasses from what it has.
56
94
 
57
- - **`HomiePropertyAccumulator`** handles message routing, property and `$target` storage, dirty-node tracking, and an explicit lifecycle state machine (`HomieLifecycle`). Protocol-only; no SPAN domain knowledge.
58
- - **`HomieDeviceConsumer`** reads from the accumulator via a query API and builds typed `SpanPanelSnapshot` dataclasses. Handles power sign normalization, DSM derivation, unmapped tab synthesis, and dirty-node-aware snapshot caching.
95
+ That boundary is why `HomiePropertyAccumulator`, `HomieLifecycle` and `HomieDeviceConsumer` are **not** exported from this package: all three are flat-schema-specific rather than Homie-convention-level. The accumulator filters every topic against a single
96
+ device's prefix and stores `node prop`, which drops nearly every message under the parent/child model, and `HomieLifecycle`'s members are not Homie 5 `$state` values but a consumer-side progression encoding "one description received ⇒ ready". They live
97
+ in `span_panel_api_schema_0`, where that model is correct. The parent/child parser reaches the same result differently, replaying the retained tree through the eBus SDK and waiting for every declared device to describe itself at any depth.
59
98
 
60
99
  Changes are pushed to consumers via callbacks. Dirty-node tracking allows the snapshot builder to skip unchanged nodes, reducing per-scan CPU cost on constrained hardware.
61
100
 
@@ -76,7 +115,7 @@ This means the library can be dropped into any asyncio application — including
76
115
 
77
116
  Circuit names arrive as MQTT retained messages that may land after the Homie device transitions to `$state=ready`. The client handles this with a bounded wait during `connect()`:
78
117
 
79
- 1. After the device reaches ready state, the client polls `HomieDeviceConsumer.circuit_nodes_missing_names()` every 250ms.
118
+ 1. After the device reaches ready state, the client polls the resolved adapter's `circuit_nodes_missing_names()` every 250ms — a `SchemaAdapter` member, so both parsers answer it in their own terms.
80
119
  2. As retained name properties arrive, the consumer stores them. Once all circuit-type nodes have a name, the wait returns immediately.
81
120
  3. If names have not all arrived within 10 seconds, the timeout expires (non-fatal) and the client proceeds — circuits without names will use fallback identifiers.
82
121
 
@@ -84,28 +123,44 @@ This ensures that the first `get_snapshot()` after connect returns human-readabl
84
123
 
85
124
  ### Protocols
86
125
 
87
- The library defines three structural subtyping protocols (PEP 544) that both the MQTT transport and the simulation engine implement:
126
+ The library defines structural subtyping protocols (PEP 544). All are `runtime_checkable`, so a consumer asks `isinstance` before offering a control rather than assuming the panel in front of it supports one:
88
127
 
89
128
  | Protocol | Purpose |
90
129
  | -------------------------- | ------------------------------------------------------------------------------------------ |
91
130
  | `SpanPanelClientProtocol` | Core lifecycle: `connect`, `close`, `ping`, `get_snapshot`, `register_connection_callback` |
92
131
  | `CircuitControlProtocol` | Relay and shed-priority control: `set_circuit_relay`, `set_circuit_priority` |
93
132
  | `PanelControlProtocol` | Panel-level control: `set_dominant_power_source` |
133
+ | `EvseControlProtocol` | Per-charger control: `set_evse_charge_limit(node_id, amps)` |
134
+ | `AdoptedControlProtocol` | Write to a settable property of a device this library models nothing for |
94
135
  | `StreamingCapableProtocol` | Push-based updates: `register_snapshot_callback`, `start_streaming`, `stop_streaming` |
95
136
 
96
- Integration code programs against these protocols, not transport-specific classes.
137
+ The first five differ in subject, not just in name. `EvseControlProtocol` is separate from `PanelControlProtocol` because several chargers may be commissioned at once and every call names which one. `AdoptedControlProtocol` differs in kind: the curated
138
+ setters name a control this library understands and translate or bound the value on the way out, while this one names a property by its wire address and passes the value through, because the declaration is all anybody here knows about it. That write is
139
+ authorised by the snapshot rather than by its arguments — the transport resolves the property against the current `adopted_devices` and refuses anything it does not find carrying a set topic, so a device this library _does_ model cannot be addressed
140
+ through it.
141
+
142
+ A seventh protocol, `SchemaAdapter`, is the bootstrap-to-parser contract rather than a consumer-facing one; it is what an adapter distribution implements and what discovery checks. Integration code programs against the protocols above, not against
143
+ transport-specific classes.
97
144
 
98
145
  ### Snapshots
99
146
 
100
147
  All panel state is represented as immutable, frozen dataclasses:
101
148
 
102
- | Dataclass | Content |
103
- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
104
- | `SpanPanelSnapshot` | Complete panel state: power, energy, grid/DSM state, hardware status, per-leg voltages, power flows, lugs current, circuits, battery, PV, EVSE |
105
- | `SpanCircuitSnapshot` | Per-circuit: power, energy, relay state, priority, tabs, device type, breaker rating, current, `$target` pending state |
106
- | `SpanBatterySnapshot` | BESS: SoC percentage, SoE kWh, vendor/product metadata, nameplate capacity |
107
- | `SpanPVSnapshot` | PV inverter: vendor/product metadata, nameplate capacity |
108
- | `SpanEvseSnapshot` | EVSE (EV charger): status, lock state, advertised current, vendor/product/serial/version metadata |
149
+ | Dataclass | Content |
150
+ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
151
+ | `SpanPanelSnapshot` | Complete panel state: power, energy, grid/DSM state, hardware status, per-leg voltages, power flows, lugs current, shed forecast, circuits, battery, PV, EVSE, MID |
152
+ | `SpanCircuitSnapshot` | Per-circuit: power, energy, relay state, priority, tabs, device type, breaker rating, current, `$target` pending state |
153
+ | `SpanBatterySnapshot` | BESS: SoC percentage, SoE kWh, own meter reading, communication state, link health, `model` / `part_number`, nameplate capacity |
154
+ | `SpanPVSnapshot` | PV inverter: link health, `model` / `part_number`, nameplate capacity |
155
+ | `SpanEvseSnapshot` | EVSE (EV charger): status, lock state, advertised current, link health, `model` / `part_number` / serial / version metadata |
156
+ | `SpanMidSnapshot` | Microgrid Interconnect Device: islanding state, grid state, grid-forming entity |
157
+ | `AdoptedDevice` | A device type this library models nothing for, carried whole: identity, readings, proxy link |
158
+ | `ExtensionProperty` | A vendor property on a device this library _does_ model, with its value and the subject it hangs off |
159
+
160
+ Identity is normalised across every DER class: **`model` is the human designation and `part_number` is the SKU**, on `battery`, `evse` and `pv` alike. `product_name` was retired in 3.0.0 — see the changelog, because `battery.model` changes value for
161
+ existing flat users at that upgrade.
162
+
163
+ `mid`, `adopted_devices`, `extension_properties` and the per-DER link-health fields exist only under the parent/child schema. They are `None` or empty on a flat panel rather than absent, so a consumer reads the same snapshot type either way.
109
164
 
110
165
  ## Usage
111
166
 
@@ -128,7 +183,15 @@ async def main():
128
183
 
129
184
  # Get a point-in-time snapshot
130
185
  snapshot = await client.get_snapshot()
131
- print(f"Grid power: {snapshot.instant_grid_power_w}W")
186
+ # The upstream lugs' own meter. That is grid flow only where the lugs are
187
+ # the utility connection point; a BESS wired ahead of them, or a panel fed
188
+ # by another panel, makes it this panel's feed instead. `power_flow_grid`
189
+ # is the site-level figure in every topology.
190
+ if snapshot.lugs_at_service_entrance:
191
+ print(f"Grid power: {snapshot.instant_grid_power_w}W")
192
+ else:
193
+ print(f"Panel feed: {snapshot.instant_grid_power_w}W")
194
+ print(f"Grid power: {snapshot.power_flow_grid}W")
132
195
  print(f"Firmware: {snapshot.firmware_version}")
133
196
  print(f"Circuits: {len(snapshot.circuits)}")
134
197
 
@@ -346,10 +409,21 @@ All exceptions inherit from `SpanPanelError`:
346
409
  | `SpanPanelTimeoutError` | Request or connection timed out |
347
410
  | `SpanPanelValidationError` | Data validation failure |
348
411
  | `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints |
349
- | `SpanPanelServerError` | Panel returned HTTP 500 |
412
+ | `SpanPanelServerError` | Panel answered 5xx, or answered `200` with a body that cannot be used — "not ready yet" |
413
+
414
+ Three more are specific to the hot-loading model, and they are separate because the remedy differs:
415
+
416
+ | Exception | Cause | Remedy |
417
+ | ----------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------- |
418
+ | `SpanPanelAdapterMissingError` | Known schema, no installed parser for it | Install the named package |
419
+ | `SpanPanelSchemaVersionError` | The panel reports a `data-model-version` no adapter can even be named for | Nothing to install yet — report the value |
420
+ | `SpanPanelAdapterIncompatibleError` | The required adapter is installed but was built against another contract | Installing more cannot help — align versions |
421
+
422
+ Reporting the third as the first would send someone to install a package they already have.
350
423
 
351
424
  `SpanPanelStaleDataError` is distinct from `SpanPanelConnectionError`: the former means the client is running but data cannot be trusted right now (transient disconnect, or panel-declared not-ready); the latter means the initial connect failed and the
352
- client cannot be used at all.
425
+ client cannot be used at all. `SpanPanelServerError` covers the whole 5xx class deliberately: a booting panel brings its network stack and reverse proxy up before the application behind them, so it _answers_ rather than refuses, and that has to be
426
+ distinguishable from a 4xx that will not fix itself on its own.
353
427
 
354
428
  ```python
355
429
  from span_panel_api import (
@@ -385,28 +459,67 @@ The `PanelCapability` flag enum advertises transport features at runtime:
385
459
  | `CIRCUIT_CONTROL` | Can set relay state and shed priority |
386
460
  | `BATTERY_SOE` | Battery state-of-energy available |
387
461
 
462
+ ## Reference Payloads
463
+
464
+ 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:
465
+
466
+ ```python
467
+ from span_panel_api.reference_payloads import homie_schema, homie_schema_types
468
+
469
+ document = homie_schema() # the captured GET /api/v2/homie/schema response
470
+ types = homie_schema_types() # its `types` map, typed as HomieSchemaTypes
471
+ ```
472
+
473
+ `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.
474
+
475
+ The parent/child device tree is the schema_1 counterpart and ships from that adapter, with the parser that can interpret it:
476
+
477
+ ```python
478
+ from span_panel_api_schema_1.reference_payloads import devices_from_tree, parent_child_tree
479
+
480
+ devices = devices_from_tree(parent_child_tree())
481
+ ```
482
+
483
+ Each payload carries the version of the release it shipped in. Pin a version and you read the bytes that version was written against.
484
+
388
485
  ## Project Structure
389
486
 
487
+ One repository, three distributions. The bootstrap is at the root; each parser is a workspace member under `packages/`, published separately and versioned on its own axis.
488
+
390
489
  ```text
391
- src/span_panel_api/
392
- ├── __init__.py # Public API exports
393
- ├── auth.py # v2 HTTP provisioning (register, cert, schema, passphrase)
394
- ├── const.py # Panel state constants (DSM, relay)
395
- ├── detection.py # detect_api_version() DetectionResult
396
- ├── exceptions.py # Exception hierarchy
397
- ├── factory.py # create_span_client() → SpanMqttClient
398
- ├── models.py # Snapshot dataclasses (panel, circuit, battery, PV)
399
- ├── phase_validation.py # Electrical phase utilities
400
- ├── protocol.py # PEP 544 protocols + PanelCapability flags
490
+ src/span_panel_api/ # distribution: span-panel-api (no parser)
491
+ ├── __init__.py # Public API exports
492
+ ├── _http.py # Shared httpx plumbing / client ownership rules
493
+ ├── adapters.py # installed_adapter_keys(), resolve_adapter() metadata, then lazy import
494
+ ├── auth.py # v2 HTTP provisioning (register, cert, schema, passphrase)
495
+ ├── const.py # Panel state constants (DSM, relay)
496
+ ├── detection.py # detect_api_version() → DetectionResult
497
+ ├── dispatch.py # select_adapter_key() what does this panel need?
498
+ ├── exceptions.py # Exception hierarchy
499
+ ├── factory.py # create_span_client() SpanMqttClient
500
+ ├── models.py # Snapshot dataclasses (panel, circuit, battery, PV, EVSE, MID, adopted)
501
+ ├── phase_validation.py # Electrical phase utilities
502
+ ├── protocol.py # PEP 544 protocols, SchemaAdapter, PanelCapability flags
503
+ ├── schema_drift.py # Reporting a panel that outruns what we can read
504
+ ├── reference_payloads/ # Captured GET /api/v2/homie/schema, shipped as package data
401
505
  └── mqtt/
402
506
  ├── __init__.py
403
- ├── accumulator.py # HomiePropertyAccumulator (Homie v5 protocol layer)
404
- ├── async_client.py # NullLock + AsyncMQTTClient (HA core pattern)
405
- ├── client.py # SpanMqttClient (all three protocols)
406
- ├── connection.py # AsyncMqttBridge (event-loop-driven, no threads)
407
- ├── const.py # MQTT/Homie constants + UUID helpers
408
- ├── homie.py # HomieDeviceConsumer (SPAN snapshot builder)
409
- └── models.py # MqttClientConfig, MqttTransport
507
+ ├── async_client.py # NullLock + AsyncMQTTClient (HA core pattern)
508
+ ├── client.py # SpanMqttClient (transport + control protocols)
509
+ ├── connection.py # AsyncMqttBridge (event-loop-driven, no threads)
510
+ ├── const.py # MQTT/Homie constants + UUID helpers
511
+ └── models.py # MqttClientConfig, MqttTransport
512
+
513
+ packages/schema-0/ # distribution: span-panel-api-schema-0
514
+ └── src/span_panel_api_schema_0/
515
+ # Flat parser: HomiePropertyAccumulator, HomieLifecycle,
516
+ # HomieDeviceConsumer, field metadata, SCHEMA_ANCHOR
517
+
518
+ packages/schema-1/ # distribution: span-panel-api-schema-1
519
+ ├── spec/ # eBus capability catalogs, byte-copied; checked against, never parsed
520
+ └── src/span_panel_api_schema_1/
521
+ # Parent/child parser: ControllerRoutes, snapshot mapper,
522
+ # adoption, catalog validator, spec_lock.json, reference payloads
410
523
  ```
411
524
 
412
525
  ## Development