span-panel-api 3.0.0b13__tar.gz → 3.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. span_panel_api-3.0.1/CHANGELOG.md +615 -0
  2. span_panel_api-3.0.0b13/README.md → span_panel_api-3.0.1/PKG-INFO +158 -44
  3. span_panel_api-3.0.0b13/PKG-INFO → span_panel_api-3.0.1/README.md +131 -63
  4. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/pyproject.toml +55 -15
  5. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/__init__.py +2 -0
  6. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/auth.py +57 -13
  7. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/dispatch.py +1 -1
  8. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/models.py +1 -2
  9. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/mqtt/client.py +1 -1
  10. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/mqtt/connection.py +14 -1
  11. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_adapters_discovery.py +2 -2
  12. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_detection_auth.py +105 -0
  13. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_public_api_unchanged.py +40 -0
  14. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_one_against_simulator.py +2 -2
  15. span_panel_api-3.0.1/tests/test_ssl_context.py +234 -0
  16. span_panel_api-3.0.0b13/CHANGELOG.md +0 -771
  17. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/.gitignore +0 -0
  18. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/LICENSE +0 -0
  19. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/_http.py +0 -0
  20. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/adapters.py +0 -0
  21. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/const.py +0 -0
  22. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/detection.py +0 -0
  23. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/exceptions.py +0 -0
  24. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/factory.py +0 -0
  25. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/mqtt/__init__.py +0 -0
  26. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/mqtt/async_client.py +0 -0
  27. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/mqtt/const.py +0 -0
  28. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/mqtt/models.py +0 -0
  29. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/phase_validation.py +0 -0
  30. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/protocol.py +0 -0
  31. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/py.typed +0 -0
  32. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/reference_payloads/README.md +0 -0
  33. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/reference_payloads/__init__.py +0 -0
  34. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/reference_payloads/homie_schema.json +0 -0
  35. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/src/span_panel_api/schema_drift.py +0 -0
  36. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/conftest.py +0 -0
  37. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  38. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  39. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  40. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/fixtures/flat_wire.json +0 -0
  41. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  42. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/fixtures/v2/README.md +0 -0
  43. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/fixtures/v2/status.json +0 -0
  44. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/simulation_fixtures/circuits.response.txt +0 -0
  45. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/simulation_fixtures/panel.response.txt +0 -0
  46. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/simulation_fixtures/soe.response.txt +0 -0
  47. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/simulation_fixtures/status.response.txt +0 -0
  48. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_accumulator.py +0 -0
  49. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_adopted_control.py +0 -0
  50. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_adoption.py +0 -0
  51. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_async_mqtt_client.py +0 -0
  52. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_auth_and_homie_helpers.py +0 -0
  53. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_catalog_divergence.py +0 -0
  54. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_exceptions.py +0 -0
  55. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_factory_dispatch.py +0 -0
  56. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_field_metadata.py +0 -0
  57. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_live_flat_differential.py +0 -0
  58. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_mqtt_bridge.py +0 -0
  59. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_mqtt_client_connection.py +0 -0
  60. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_mqtt_connect_flow.py +0 -0
  61. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_mqtt_debounce.py +0 -0
  62. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_mqtt_homie.py +0 -0
  63. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_packaging.py +0 -0
  64. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_phase_validation_configs.py +0 -0
  65. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_phase_validation_errors.py +0 -0
  66. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_protocol_conformance.py +0 -0
  67. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_protocol_models.py +0 -0
  68. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_redispatch_on_reconnect.py +0 -0
  69. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_reference_tree_values.py +0 -0
  70. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_generation_cross_check.py +0 -0
  71. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_migration_delta.py +0 -0
  72. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_one_adapter.py +0 -0
  73. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_one_charge_limit.py +0 -0
  74. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_one_circuits.py +0 -0
  75. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_one_conformance.py +0 -0
  76. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_one_connection_health.py +0 -0
  77. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_one_devices.py +0 -0
  78. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_one_discovery.py +0 -0
  79. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_one_extension.py +0 -0
  80. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_one_panel.py +0 -0
  81. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_one_pcs.py +0 -0
  82. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_one_service_entrance.py +0 -0
  83. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_one_shed_forecast.py +0 -0
  84. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_one_snapshot.py +0 -0
  85. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_one_transport.py +0 -0
  86. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_provenance.py +0 -0
  87. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_schema_zero_adapter.py +0 -0
  88. {span_panel_api-3.0.0b13 → span_panel_api-3.0.1}/tests/test_shared_http_client.py +0 -0
@@ -0,0 +1,615 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ 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.1]
11
+
12
+ ### Fixed
13
+
14
+ - **`SpanPanelAdapterIncompatibleError` is exported from the top-level package.** 3.0.0 documented it there — in this changelog, in the README's error table, and in `SpanMqttClient.connect`'s own docstring, which names it as something the caller receives —
15
+ but it was omitted from `__init__.py`, so the only way to catch it was `from span_panel_api.exceptions import ...`, a path nothing else in the documentation uses. `resolve_adapter` raises it into caller hands rather than logging it, so a consumer
16
+ following the documented API got an `ImportError` at exactly the point it was trying to handle a real failure. Purely additive: the class, its attributes and its raise sites are unchanged.
17
+
18
+ ### Added
19
+
20
+ - **A guard that derives the public exception surface from the module instead of transcribing it.** The existing pin compares `__all__` against a hand-written set, which catches the two drifting apart but not a name absent from both — which is precisely
21
+ how the omission above shipped. The new check enumerates every exception class defined in `span_panel_api.exceptions` and fails if one is not exported.
22
+ - **Python version classifiers in the published metadata**, so the supported version is stated rather than inferred, and so the README's Python badge is read from PyPI rather than hardcoded. The hardcoded badge read `3.10+` for the whole of 3.0.0, five
23
+ minor versions below the real floor, because nothing connected it to `requires-python`.
24
+
25
+ ## [3.0.0]
26
+
27
+ `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
28
+ by installing a package rather than by upgrading the transport.
29
+
30
+ ### Removed
31
+
32
+ - **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:
33
+
34
+ ```console
35
+ # flat-schema panels, firmware r202603-r202627
36
+ pip install "span-panel-api[schema-0]"
37
+
38
+ # parent/child panels, firmware r202633+
39
+ pip install "span-panel-api[schema-1]"
40
+ ```
41
+
42
+ 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
43
+ 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.
44
+
45
+ - **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
46
+ 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
47
+ received ⇒ ready", which is the flat readiness model. They now live in `span_panel_api_schema_0`.
48
+ - **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).
49
+
50
+ ### Changed
51
+
52
+ - **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:
53
+ 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
54
+ 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
55
+ 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.
56
+ - **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.
57
+ - **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
58
+ read but whose form is non-canonical (`1`, `1_0`) 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
59
+ produces plausible but wrong power and energy figures.
60
+ - **`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`
61
+ 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.
62
+
63
+ ### Added
64
+
65
+ #### Adapter architecture
66
+
67
+ - **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
68
+ 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
69
+ 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.
70
+ - **`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
71
+ 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.
72
+ - **`resolve_adapter(key, reason)`** — the single place a missing adapter becomes a named error, used by both dispatch and the transport's default path.
73
+ - **`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".
74
+ - **`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
75
+ 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
76
+ 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.
77
+ - **`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
78
+ a parser must. Dispatch happens wherever a parser is built, so a directly constructed client dispatches exactly as the factory path does.
79
+ - **`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.
80
+
81
+ #### Surviving a firmware upgrade
82
+
83
+ - **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
84
+ **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.
85
+ - **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
86
+ 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
87
+ wait expired produces neither, so running out of attempts means stranded until somebody reloads by hand.
88
+ - **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
89
+ 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
90
+ what is left is one request every thirty seconds to a device on your own network.
91
+ - **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,
92
+ because a reload is the user's only move and nothing else was going to tell them.
93
+
94
+ #### Injected HTTP client on the runtime path
95
+
96
+ - **`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
97
+ 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
98
+ 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.
99
+
100
+ #### Reference payloads shipped in the wheel
101
+
102
+ - **`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
103
+ 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
104
+ 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
105
+ `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
106
+ parser that can interpret it.
107
+
108
+ #### New snapshot surface
109
+
110
+ 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.
111
+
112
+ - **`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
113
+ `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.
114
+ - **`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
115
+ 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
116
+ 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.
117
+ - **`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
118
+ 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.
119
+ - **`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
120
+ 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
121
+ 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
122
+ negation table. Defaults `True`, because flat firmware predates chaining and a flat panel's lugs really are its service entrance.
123
+ - **`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
124
+ 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
125
+ 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
126
+ `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,
127
+ 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.
128
+ - **`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
129
+ `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
130
+ 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
131
+ 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.
132
+ - **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 —
133
+ 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
134
+ 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
135
+ a defaulted zero would be indistinguishable from the worst forecast the capability can report.
136
+ - **`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
137
+ 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
138
+ 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
139
+ alternative. Extra instances of a modelled type are deliberately not adopted either: a second BESS is a multiplicity limit, not an unmodelled device.
140
+ - **`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
141
+ 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
142
+ evidence and the topology waits.
143
+ - **`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
144
+ 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
145
+ 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
146
+ 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.
147
+ - **`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;
148
+ 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
149
+ 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
150
+ _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.
151
+ - **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
152
+ 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
153
+ triage and no set topic, and there is no member a write path could be built from.
154
+
155
+ ### Fixed
156
+
157
+ - **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
158
+ 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
159
+ 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
160
+ [#148](https://github.com/SpanPanel/span-panel-api/pull/148).
161
+
162
+ ## [2.6.4] - 05/2026
163
+
164
+ ### Fixed
165
+
166
+ - **MQTT reconnect now self-heals after persistent failure** — `AsyncMqttBridge._reconnect_loop` rebuilds the paho client from scratch (re-fetching the panel CA, constructing a fresh client, resetting the Homie accumulator) after
167
+ `MQTT_FULL_REBUILD_AFTER_FAILURES` (3) consecutive failures, or immediately on any `ssl.SSLError`. The previous behavior pinned the panel's CA certificate into the paho client once at `connect()` time and re-used it across all reconnect attempts; if the
168
+ panel rotated its private CA — most plausibly during a firmware upgrade — every subsequent reconnect raised `ssl.SSLCertVerificationError` (caught by the broad `OSError` clause and silently retried) and the bridge could not recover without a config-entry
169
+ reload. The rebuild mirrors what a manual reload does without going through HA's `config_entry` teardown, so entities stay registered and the integration's grace-period logic continues to apply unchanged. The threshold-cadence design (counter reset on
170
+ every rebuild attempt, success or fail) keeps the recovery path active throughout extended outages — multi-day disconnections recover whenever the panel becomes usable again, including if the CA rotates a second time mid-outage. See
171
+ `SpanPanel_Docs/span-panel-api/2026-05-17-mqtt-ca-refresh-on-reconnect-design.md` for the full design.
172
+
173
+ ### Added
174
+
175
+ - **`AsyncMqttBridge._rebuild_client()`** — internal recovery method invoked by the reconnect loop on persistent failure. Re-fetches the panel CA via `download_ca_cert()`, builds a fresh paho client via the new `_make_paho_client()` factory, fires the
176
+ optional pre-rebuild callback so consumers can reset their own state, tears down the old client, and submits the initial connect via the executor. Restores the previous client on any failure.
177
+ - **`AsyncMqttBridge.set_pre_rebuild_callback()`** — internal API for `SpanMqttClient` to register a hook that fires before each rebuild. Used to reset the Homie accumulator so retained messages on the new subscription start from a clean slate.
178
+ - **`MQTT_FULL_REBUILD_AFTER_FAILURES`** constant in `mqtt/const.py`.
179
+
180
+ ### Changed
181
+
182
+ - **`SpanPanelAPIError` now in the bridge's CA-fetch exception list** — a `download_ca_cert()` failure during rebuild (e.g. panel returns HTTP 502 mid-outage) is caught, logged at WARNING, and the loop continues retrying with the previous client instead of
183
+ letting the reconnect task die.
184
+
185
+ ## [2.6.2] - 04/2026
186
+
187
+ ### Changed
188
+
189
+ - **Reconnect loop log noise reduced** — `SpanMqttClient._reconnect_loop` now splits the catch-all exception handler in two: expected transient failures (`OSError` family — refused connection, DNS miss, socket timeout, `ssl.SSLError`) log a one-line
190
+ WARNING with the exception repr, while unexpected exceptions retain the full traceback via `exc_info=True`. The common "panel offline" case no longer buries logs in paho/stdlib stack frames that add no diagnostic signal; genuinely unknown failures still
191
+ surface full tracebacks for support-ticket triage.
192
+
193
+ ## [2.6.1] - 04/2026
194
+
195
+ ### Changed
196
+
197
+ - **`get_fqdn()` returns `str | None`** — `None` now distinguishes "no FQDN configured" (HTTP 404 or missing field) from an explicit empty string. Callers that treated `""` as "not registered" must update to check for `None`.
198
+ - **Connection callback errors logged at WARNING** — `SpanMqttClient._on_connection_change` now logs callback exceptions via `_LOGGER.warning(..., exc_info=True)` instead of `_LOGGER.exception(...)`, consistent with `_dispatch_snapshot`.
199
+ - **Reconnect loop catches all exceptions** — `AsyncMqttBridge._reconnect_loop` no longer silently drops on non-`OSError` failures (e.g. `WebsocketConnectionError`, `ssl.SSLError`). All exceptions are logged at WARNING and the loop keeps backing off.
200
+ - **Abnormal MQTT disconnects logged at WARNING** — disconnects where `reason_code.is_failure` is true now log at WARNING; clean disconnects continue to log at DEBUG.
201
+
202
+ ### Fixed
203
+
204
+ - **CA certificate no longer written to disk** — `AsyncMqttBridge.connect()` builds the `ssl.SSLContext` from the fetched PEM via `cadata`, eliminating the temp-file lifecycle (and the small leak window on unexpected process exit) that the prior
205
+ `tls_set(ca_certs=path)` path required.
206
+ - **Deprecated `asyncio.get_event_loop()` removed** — `_wait_for_circuit_names` now uses `time.monotonic()`. The previous code emitted a `DeprecationWarning` on Python 3.12+.
207
+ - **Negative-zero on circuit `instant_power_w`** — explicit guard replaces a cryptic `-raw or 0.0` idiom in `HomieDeviceConsumer._build_circuit`.
208
+ - **DSM grid-exchanging heuristic uses epsilon** — replaces `!= 0.0` float comparison with `abs(x) > 1.0 W`, so the `DSM_OFF_GRID` branch is actually reachable when no BESS is commissioned and lugs readings hover near zero.
209
+ - **`SpanPanelAPIError.__str__` override removed** — the override silently hid exception args beyond the first; default `Exception.__str__` is now used.
210
+ - **Paho lock-layout check at import** — `span_panel_api.mqtt.async_client` verifies on import that the `_PAHO_LOCK_ATTRS` list exactly matches paho's `*_mutex` attributes. Raises `RuntimeError` (not `assert`, so `python -O` does not bypass it) on drift.
211
+
212
+ ### Documentation
213
+
214
+ - **`register_v2()`** — docstring now warns that each call creates a new client entry on the panel; callers should persist and reuse the returned `V2AuthResponse` rather than re-registering on every restart.
215
+ - **Stale simulation transport references removed** from `protocol.py` and `models.py` module docstrings.
216
+
217
+ ## [2.6.0] - 04/2026
218
+
219
+ ### Added
220
+
221
+ - **`SpanMqttClient.register_connection_callback(cb)`** — subscribe to broker connection state transitions. Callback fires with `False` on broker disconnect and `True` on reconnect; returns an idempotent unregister function. Added to
222
+ `SpanPanelClientProtocol` so any transport that claims the protocol must implement it.
223
+ - **`SpanPanelStaleDataError`** exception — raised by `get_snapshot()` when the client is not fully live. Derives from `SpanPanelError` (not from `SpanPanelConnectionError`), because "never connected" and "running but data not currently live" are
224
+ semantically distinct states.
225
+
226
+ ### Changed
227
+
228
+ - **`get_snapshot()` contract** — now raises `SpanPanelStaleDataError` when the bridge is not connected or the Homie device has not reached ready state. Previously, the method silently returned a snapshot built from whatever the in-memory accumulator
229
+ happened to hold, which made offline panels indistinguishable from online ones. This is the primary reason the span integration could not detect panel-offline transitions.
230
+
231
+ ### Fixed
232
+
233
+ - **Stale snapshot dispatch after bridge disconnect** — a pending snapshot-debounce timer scheduled just before a bridge disconnect could fire afterwards, delivering a snapshot built from the still-`ready()` accumulator to subscribers.
234
+ `_on_connection_change(False)` now cancels the pending timer, and `_dispatch_snapshot` is now guarded by the same liveness predicate as `get_snapshot()`, so push consumers never receive a post-disconnect stale snapshot.
235
+
236
+ ### Breaking
237
+
238
+ - Consumers of `get_snapshot()` must now handle `SpanPanelStaleDataError`. Any consumer with a broad `except Exception` (or `except SpanPanelError`) branch already handles this correctly.
239
+
240
+ ## [2.5.4] - 04/2026
241
+
242
+ ### Reverted
243
+
244
+ - **Revert accumulator to 2.5.1 behavior** — the 2.5.2 lifecycle changes (property clearing, unconditional lifecycle transition on `$state=init`, generation counter) caused false energy dip spikes on panel reboots and network interruptions. The 2.5.3
245
+ partial fix (removing the clearing) was insufficient — the unconditional lifecycle disruption on transient `$state=init` events still triggered snapshot pipeline resets that produced 0.0 energy readings. Reverted `accumulator.py` and `homie.py` to their
246
+ stable 2.5.1 state. The existing dirty-node tracking handles reboot transitions correctly without special-case lifecycle management.
247
+
248
+ ## [2.5.3] - 04/2026 (retired)
249
+
250
+ > **Retired:** Partial fix for 2.5.2 — removed property clearing but kept the lifecycle disruption that still caused false dips. Superseded by 2.5.4.
251
+
252
+ ### Fixed
253
+
254
+ - **Preserve property values on lifecycle reset** — removed the property/timestamp/target clearing from `_handle_description()`.
255
+
256
+ ## [2.5.2] - 04/2026 (retired)
257
+
258
+ > **Retired:** Lifecycle changes caused false energy dip spikes. Superseded by 2.5.4.
259
+
260
+ ### Fixed
261
+
262
+ - **Clear stale property values on panel reboot** — after a panel reboot, snapshots could mix pre-reboot and post-reboot data. The accumulator now detects reboots (including fast reboots where the broker LWT is skipped) and clears stale state before
263
+ building the next snapshot.
264
+ - **Snapshot cache invalidated on reboot** — the snapshot cache is now discarded when a reboot is detected, forcing a full rebuild from fresh data.
265
+
266
+ ## [2.5.1] - 04/2026
267
+
268
+ ### Fixed
269
+
270
+ - **Replaced `assert` with `RuntimeError` in production code** — `HomieDeviceConsumer._rebuild_dirty_circuits()` used an `assert` to guard a cached-snapshot invariant, which would be silently stripped by `python -O`. Replaced with an explicit
271
+ `RuntimeError` raise.
272
+ - **Fixed broken bandit pre-commit hook** — bandit was pinned to v1.8.3, which is incompatible with Python 3.14. It silently skipped all source files (20/20) and reported "Passed" with zero issues. Bumped to v1.9.4 which scans all files correctly.
273
+
274
+ ## [2.5.0] - 03/2026
275
+
276
+ ### Added
277
+
278
+ - **`HomiePropertyAccumulator`** — new layer that handles generic Homie v5 protocol parsing (message routing, property/target storage, dirty-node tracking) with an explicit lifecycle state machine (`HomieLifecycle`), cleanly separated from SPAN-specific
279
+ snapshot construction.
280
+ - **`$target` property support** — `SpanCircuitSnapshot` gains `relay_state_target` and `priority_target` fields, surfacing the desired-vs-actual state for relay and shed-priority commands.
281
+ - **Dirty-node snapshot caching** — `HomieDeviceConsumer.build_snapshot()` tracks which nodes changed since the last build and returns a cached snapshot when nothing is dirty, reducing per-scan CPU cost on constrained hardware.
282
+
283
+ ### Changed
284
+
285
+ - **Layered Homie consumer architecture** — `HomieDeviceConsumer` no longer handles protocol plumbing. It reads from `HomiePropertyAccumulator` via a query API (`get_prop`, `get_target`, `nodes_by_type`, etc.) and focuses solely on SPAN domain
286
+ interpretation: power sign normalization, DSM derivation, unmapped tab synthesis, and snapshot assembly.
287
+ - **`SpanMqttClient` composes both layers** — `connect()` creates an accumulator and wires it into the consumer. The public client API is unchanged.
288
+ - **Property callbacks fire only on value change** — retained messages replaying already-known values no longer trigger callback storms on MQTT reconnect.
289
+
290
+ ## [2.4.2] - 03/2026
291
+
292
+ ### Fixed
293
+
294
+ - **Moved SSL context creation to executor** — `httpx.AsyncClient()` eagerly calls `ssl.SSLContext.load_verify_locations()` with the system CA bundle, which is a blocking file I/O operation that triggers Home Assistant's event loop protection. The SSL
295
+ context is now created in an executor thread and passed to httpx via `verify=ctx`.
296
+
297
+ ## [2.4.1] - 03/2026
298
+
299
+ ### Fixed
300
+
301
+ - **Added `license = "MIT"` to package metadata** — the `pyproject.toml` was missing the license field, causing license audit failures in downstream projects (HA core hassfest).
302
+ - **Loosened httpx version constraint** — changed from `>=0.28.1,<0.29.0` to `>=0.28.1` to satisfy HA core hassfest version restriction checks.
303
+
304
+ ## [2.4.0] - 03/2026
305
+
306
+ ### Added
307
+
308
+ - **`proximity_proven` on `V2StatusInfo`** — parsed from the v2 status endpoint response (firmware 202609+). Returns `None` on older panels where the field is absent, allowing callers to distinguish "not proven" from "unknown."
309
+ - **`HomieSchemaTypes` type alias** — replaces raw `dict[str, dict[str, object]]` throughout the codebase for Homie schema type signatures.
310
+ - **`log_schema_drift` test coverage** — raised `field_metadata.py` coverage from 58% to 98%.
311
+
312
+ ### Changed
313
+
314
+ - **Injected HTTP client for v2 auth** — `detect_api_version`, `register_v2`, `download_ca_cert`, and other bootstrap functions accept an optional `httpx_client` parameter. Consumers (e.g. Home Assistant) can pass their managed client instead of the
315
+ library creating ad-hoc ones.
316
+ - **Blocking file I/O moved to executor** — temp CA cert file write and cleanup in `AsyncMqttBridge.connect()` and `disconnect()` now run in an executor thread instead of on the event loop.
317
+ - **Narrowed CA cert download exception handling** — `connect()` catches specific `OSError`, `SpanPanelConnectionError`, and `SpanPanelTimeoutError` instead of bare `Exception` when fetching the CA certificate.
318
+ - **Removed `verify=False` from fallback HTTP client** — the library's internal fallback `httpx.AsyncClient` no longer sets `verify=False`. All bootstrap URLs are plain HTTP so the flag was irrelevant; removing it avoids misleading security impressions.
319
+
320
+ ### Removed
321
+
322
+ - **59 low-value tests** — stripped tests that exercised Python language mechanics (dataclass construction, frozen, slots, IntFlag), tautological assertions, fragile source-code string inspection, redundant export checks, and duplicates across files. Test
323
+ count: 310 → 251, coverage maintained at 96%.
324
+
325
+ ## [2.3.2] - 03/2026
326
+
327
+ ### Added
328
+
329
+ - **FQDN management endpoints** — `register_fqdn()`, `get_fqdn()`, `delete_fqdn()` for managing the panel's TLS certificate SAN via `/api/v2/dns/fqdn` ([spanio/SPAN-API-Client-Docs#10](https://github.com/spanio/SPAN-API-Client-Docs/issues/10))
330
+
331
+ ## [2.3.1] - 03/2026
332
+
333
+ ### Fixed
334
+
335
+ - **MQTT connection errors now wrapped as `SpanPanelConnectionError`** — `OSError` subclasses raised during MQTT broker connection (DNS resolution failure, connection refused, network unreachable, etc.) are now caught and wrapped as
336
+ `SpanPanelConnectionError`. Previously these propagated as unhandled exceptions, preventing consumers from handling them gracefully.
337
+
338
+ ## [2.3.0] - 03/2026
339
+
340
+ ### Removed
341
+
342
+ - **Simulation engine removed** — `DynamicSimulationEngine`, `SimulationConfig`, and all simulation-related modules have been removed from the library. Simulation is now handled by the standalone SPAN Panel Simulator add-on.
343
+
344
+ ## [2.2.4] - 03/2026
345
+
346
+ ### Fixed
347
+
348
+ - **Negative zero on idle circuits** — Circuit power negation (`-raw_power_w`) produced IEEE 754 `-0.0` when the panel reported `0.0` for an idle circuit. The value is now normalized to positive zero after negation.
349
+
350
+ ## [2.2.3] - 03/2026
351
+
352
+ ### Changed
353
+
354
+ - **Panel size sourced from Homie schema** — `panel_size` is now derived from the circuit `space` property format in the Homie schema (`GET /api/v2/homie/schema`), which declares the valid range as `"1:N:1"` where N is the panel size. This replaces a
355
+ non-deterministic heuristic that inferred panel size from the highest occupied breaker tab, which would undercount when trailing positions were empty.
356
+ - **`SpanMqttClient.connect()` fetches schema internally** — the client automatically calls `get_homie_schema()` during `connect()` and passes the panel size to `HomieDeviceConsumer`. Callers no longer need to fetch or pass `panel_size`.
357
+ - **`SpanPanelSnapshot.panel_size`** — type changed from `int | None` to `int`; always populated from the schema
358
+ - **`V2HomieSchema.panel_size`** — new property that parses the schema's circuit space format to extract the authoritative panel size
359
+ - **`V2HomieSchema` exported** from package public API
360
+ - **`HomieDeviceConsumer` requires `panel_size`** — new required constructor parameter; unmapped tabs now fill to the schema-defined panel size rather than deriving from circuit data
361
+ - **`create_span_client()` simplified** — `panel_size` parameter removed; schema is fetched internally by `SpanMqttClient.connect()`
362
+
363
+ ### Removed
364
+
365
+ - **MQTT `core/panel-size` topic parsing** — removed from `HomieDeviceConsumer`; panel size comes from the schema, not a runtime MQTT property
366
+
367
+ ## [2.0.0] - 02/2026
368
+
369
+ v2.0.0 is a ground-up rewrite. The REST/OpenAPI transport has been removed entirely in favor of MQTT/Homie — the SPAN Panel's native v2 protocol. This is a breaking change: all consumer code must be updated to use the new API surface.
370
+
371
+ ### v1.x Sunset
372
+
373
+ Package versions prior to 2.0.0 depend on the SPAN v1 REST API. SPAN will sunset v1 firmware at the end of 2026, at which point v1.x releases of this package will cease to function. Users should upgrade to 2.0.0.
374
+
375
+ ### Breaking Changes
376
+
377
+ - **REST transport removed** — `SpanPanelClient`, `SpanRestClient`, the `generated_client/` OpenAPI layer, and all REST-related modules have been deleted
378
+ - **No more polling** — `get_status()`, `get_panel_state()`, `get_circuits()`, `get_storage_soe()` replaced by `get_snapshot()` returning a single `SpanPanelSnapshot`
379
+ - **Protocol-based API** — consumers code against `SpanPanelClientProtocol`, `CircuitControlProtocol`, and `StreamingCapableProtocol` (PEP 544), not concrete classes
380
+ - **Authentication changed** — passphrase-based v2 registration via `register_v2()` replaces v1 token-based auth; factory handles this automatically
381
+ - **paho-mqtt is now required** — moved from optional `[mqtt]` extra to a core dependency
382
+ - **Circuit IDs are UUIDs** — dashless UUID strings replace integer circuit IDs
383
+ - **Shed priority values changed** — v2 uses `NEVER` / `SOC_THRESHOLD` / `OFF_GRID` instead of v1's `MUST_HAVE` / `NICE_TO_HAVE` / `NON_ESSENTIAL`
384
+ - **`SpanPanelRetriableError` removed** — retry logic is no longer in the library (no REST polling)
385
+ - **`set_async_delay_func()` removed** — no retry delay hook needed for MQTT transport
386
+ - **`cache_window` parameter removed** — no caching needed; MQTT delivers state changes in real time
387
+ - **`attrs`, `python-dateutil` dependencies removed**
388
+
389
+ ### Added
390
+
391
+ - **MQTT/Homie transport** (`span_panel_api.mqtt`):
392
+ - `SpanMqttClient` — implements all three protocols (panel, circuit control, streaming)
393
+ - `AsyncMqttBridge` — paho-mqtt v2 wrapper with TLS/WebSocket, event-loop-driven socket I/O (no threads)
394
+ - `HomieDeviceConsumer` — Homie v5 state machine parsing MQTT topics into snapshots
395
+ - `MqttClientConfig` — frozen configuration with transport type and TLS settings
396
+ - **Snapshot dataclasses** — immutable `SpanPanelSnapshot`, `SpanCircuitSnapshot`, `SpanBatterySnapshot`, `SpanPVSnapshot`, `SpanEvseSnapshot` with v2-native fields
397
+ - **v2 auth functions** — `register_v2()`, `download_ca_cert()`, `get_homie_schema()`, `regenerate_passphrase()`
398
+ - **API version detection** — `detect_api_version()` probes `/api/v2/status` and returns `DetectionResult`
399
+ - **Factory function** — `create_span_client()` handles registration and returns a configured `SpanMqttClient`
400
+ - **PV/BESS metadata** — vendor name, product name, nameplate capacity parsed from Homie device tree
401
+ - **Power flows** — `power_flow_pv`, `power_flow_battery`, `power_flow_grid`, `power_flow_site` on panel snapshot
402
+ - **Lugs current** — per-phase upstream/downstream current (A) on panel snapshot
403
+ - **Per-leg voltages** — `l1_voltage`, `l2_voltage` on panel snapshot
404
+ - **Panel metadata** — `dominant_power_source`, `vendor_cloud`, `wifi_ssid`, `panel_size`, `main_breaker_rating_a`
405
+ - **Streaming callbacks** — `register_snapshot_callback()` + `start_streaming()` / `stop_streaming()` for real-time push
406
+ - **Snapshot debounce** — `snapshot_interval` parameter on `SpanMqttClient` (default 1.0s) rate-limits `build_snapshot()` + callback dispatch; set to 0 for immediate (no debounce). Runtime adjustment via `set_snapshot_interval()`
407
+ - **`PanelCapability` flag enum** — runtime feature advertisement (`EBUS_MQTT`, `PUSH_STREAMING`, `CIRCUIT_CONTROL`, `BATTERY_SOE`)
408
+
409
+ ### Changed
410
+
411
+ - `412 Precondition Failed` now treated as auth error (`AUTH_ERROR_CODES` updated)
412
+ - Version bumped from 1.1.14 to 2.0.0
413
+ - Python requirement relaxed to `>=3.10` (from `3.12+`)
414
+
415
+ ### Removed
416
+
417
+ - `src/span_panel_api/rest/` — entire REST client directory
418
+ - `src/span_panel_api/client.py` — backward-compat shim
419
+ - `src/span_panel_api/generated_client/` — OpenAPI v1 generated models
420
+ - `generate_client.py` — OpenAPI client generator script
421
+ - `examples/` directory (YAML configs moved to `tests/fixtures/configs/`)
422
+ - `DeprecationInfo`, `CircuitCorrelationProtocol`, `CorrelationUnavailableError`, `SpanPanelRetriableError`
423
+ - `PanelCapability.REST_V1`, `PanelCapability.SIMULATION` flags
424
+ - HTTP/retry constants from `const.py`
425
+ - `openapi.json` specification file
426
+
427
+ ## [2.2.1] - 03/2026
428
+
429
+ ### Added
430
+
431
+ - **`PanelControlProtocol`** — new protocol interface for panel-level settable properties, separate from `CircuitControlProtocol`
432
+ - **`set_dominant_power_source()`** — publishes a Dominant Power Source override to the panel's core node via MQTT
433
+ - **`find_node_by_type()` made public** — renamed from `_find_node_by_type()` on `HomieDeviceConsumer` to support external callers resolving node IDs by type
434
+
435
+ ## [2.0.2] - 03/2026
436
+
437
+ ### Added
438
+
439
+ - **EVSE snapshot model** — new `SpanEvseSnapshot` dataclass with status, lock state, advertised current, and device metadata (vendor, product, part number, serial number, software version)
440
+ - **EVSE Homie parsing** — `HomieDeviceConsumer._build_evse_devices()` extracts all 9 EVSE properties from `energy.ebus.device.evse` nodes
441
+ - **Multiple EVSE support** — `SpanPanelSnapshot.evse` dict keyed by node ID supports multiple commissioned chargers
442
+ - **EVSE simulation** — `DynamicSimulationEngine` generates EVSE snapshots for circuits with `device_type == "evse"`
443
+ - **`SpanEvseSnapshot` exported** from package public API
444
+
445
+ ## [2.0.1] - 03/2026
446
+
447
+ ### Added
448
+
449
+ - **Full BESS metadata parsing** — vendor name, product name, model, serial number, software version, nameplate capacity, and connected state from Homie BESS node
450
+ - **README documentation** — event-loop I/O architecture and circuit name synchronization sections
451
+
452
+ ### Changed
453
+
454
+ - Bumped nodeenv dev dependency from 1.9.1 to 1.10.0
455
+
456
+ ## [1.1.14] - 12/2025
457
+
458
+ ### Fixed
459
+
460
+ - Recognize panel Keep-Alive at 5 sec, handle `httpx.RemoteProtocolError` defensively
461
+
462
+ ## [1.1.9] - 9/2025
463
+
464
+ ### Fixed
465
+
466
+ - Simulation mode sign correction for solar and battery power values
467
+ - Fixed battery State of Energy (SOE) calculation to use configured battery behavior instead of hardcoded time-of-day assumptions
468
+
469
+ ### Changed
470
+
471
+ - Updated GitHub Actions setup-python from v5 to v6
472
+ - Updated dev dependencies group
473
+
474
+ ## [1.1.8] - 2024
475
+
476
+ ### Fixed
477
+
478
+ - Fixed sign on power values in simulation mode
479
+
480
+ ### Changed
481
+
482
+ - Updated virtualenv from 20.33.0 to 20.34.0
483
+ - Updated GitHub Actions checkout from v4 to v5
484
+
485
+ ## [1.1.6] - 2024
486
+
487
+ ### Added
488
+
489
+ - Enhanced simulation API with YAML configuration and dynamic overrides
490
+ - Battery behavior simulation capabilities
491
+ - Phase validation functionality
492
+ - Support for host field as serial number in simulation mode
493
+ - Time-based energy accumulation in simulation
494
+ - Power fluctuation patterns for different appliance types
495
+ - Per-circuit and per-branch variation controls
496
+
497
+ ### Fixed
498
+
499
+ - Fixed authentication in simulation mode
500
+ - Fixed locking issues in simulation mode
501
+ - Fixed energy accumulation in simulation
502
+ - Fixed cache for unmapped circuits
503
+
504
+ ### Changed
505
+
506
+ - Refactored simulation to reduce code complexity
507
+
508
+ ### Removed
509
+
510
+ - Removed unused client_utils.py
511
+
512
+ ## [1.1.5] - 2024
513
+
514
+ ### Added
515
+
516
+ - Simulation mode enhancements
517
+ - Test coverage for simulation edge cases
518
+
519
+ ### Fixed
520
+
521
+ - Fixed panel constants and simulation demo
522
+ - Fixed energy accumulation in simulation
523
+
524
+ ## [1.1.4] - 2024
525
+
526
+ ### Added
527
+
528
+ - Formatting and linting scripts
529
+
530
+ ### Removed
531
+
532
+ - Removed unused client_utils.py
533
+
534
+ ## [1.1.3] - 2024
535
+
536
+ ### Fixed
537
+
538
+ - Fixed tests and linting errors
539
+ - Excluded defensive code from coverage
540
+
541
+ ## [1.1.2] - 2024
542
+
543
+ ### Added
544
+
545
+ - **Simulation mode** — complete simulation system for development and testing without physical SPAN panel
546
+ - Dead code checking
547
+ - Test coverage for simulation mode
548
+
549
+ ### Changed
550
+
551
+ - Updated ruff configuration
552
+ - Moved uncategorized tests to appropriate files
553
+
554
+ ## [1.1.1] - 2024
555
+
556
+ ### Changed
557
+
558
+ - Upgraded openapi-python-client to 0.24.0 and regenerated client
559
+ - Loosened ruff dependency constraints
560
+
561
+ ### Fixed
562
+
563
+ - Fixed tests compatibility issues
564
+
565
+ ## [1.1.0] - 2024
566
+
567
+ ### Added
568
+
569
+ - Initial release of SPAN Panel API client library
570
+ - REST/OpenAPI transport for SPAN Panel v1 firmware
571
+ - Context manager, long-lived, and manual connection patterns
572
+ - Authentication system with token-based API access
573
+ - Panel status and state retrieval
574
+ - Circuit control (relay and priority management)
575
+ - Battery storage information (SOE)
576
+ - Virtual circuits for unmapped panel tabs
577
+ - Timeout and retry configuration with exponential backoff
578
+ - Time-based caching system
579
+ - Error categorization with specific exception types
580
+ - Home Assistant integration compatibility layer
581
+ - Simulation mode for testing without physical hardware
582
+ - Development toolchain with Poetry, pytest, mypy, ruff
583
+
584
+ ---
585
+
586
+ ## Version History Summary
587
+
588
+ | Version | Date | Transport | Summary |
589
+ | ---------- | ------- | ---------- | ---------------------------------------------------------------------------------- |
590
+ | **2.5.4** | 04/2026 | MQTT/Homie | Revert accumulator to stable 2.5.1 behavior; fixes false energy dip spikes |
591
+ | **2.5.3** | 04/2026 | MQTT/Homie | _(retired)_ Partial fix — still caused false dips from lifecycle disruption |
592
+ | **2.5.2** | 04/2026 | MQTT/Homie | _(retired)_ Lifecycle changes caused false energy dip spikes |
593
+ | **2.5.1** | 04/2026 | MQTT/Homie | Replace assert with RuntimeError; fix bandit pre-commit hook |
594
+ | **2.5.0** | 03/2026 | MQTT/Homie | Homie accumulator layer, $target support, dirty-node snapshot caching |
595
+ | **2.4.2** | 03/2026 | MQTT/Homie | SSL context creation moved to executor |
596
+ | **2.4.1** | 03/2026 | MQTT/Homie | License metadata, loosened httpx constraint |
597
+ | **2.4.0** | 03/2026 | MQTT/Homie | proximityProven, injected HTTP client, executor file I/O, type alias, test cleanup |
598
+ | **2.3.2** | 03/2026 | MQTT/Homie | FQDN management endpoints |
599
+ | **2.3.1** | 03/2026 | MQTT/Homie | MQTT connection errors wrapped as SpanPanelConnectionError |
600
+ | **2.3.0** | 03/2026 | MQTT/Homie | Simulation engine removed |
601
+ | **2.2.4** | 03/2026 | MQTT/Homie | Negative zero fix on idle circuits |
602
+ | **2.2.3** | 03/2026 | MQTT/Homie | Panel size from Homie schema; `panel_size` always populated on snapshot |
603
+ | **2.0.2** | 03/2026 | MQTT/Homie | EVSE (EV charger) snapshot model, Homie parsing, simulation support |
604
+ | **2.0.1** | 03/2026 | MQTT/Homie | Full BESS metadata parsing, README documentation |
605
+ | **2.0.0** | 02/2026 | MQTT/Homie | Ground-up rewrite: MQTT-only, protocol-based API, real-time push, PV/BESS metadata |
606
+ | **1.1.14** | 12/2025 | REST | Keep-Alive and RemoteProtocolError handling |
607
+ | **1.1.9** | 9/2025 | REST | Simulation sign corrections |
608
+ | **1.1.8** | 2024 | REST | Simulation power sign fix |
609
+ | **1.1.6** | 2024 | REST | YAML simulation API, battery simulation |
610
+ | **1.1.5** | 2024 | REST | Simulation edge cases |
611
+ | **1.1.4** | 2024 | REST | Formatting and linting |
612
+ | **1.1.3** | 2024 | REST | Test and lint fixes |
613
+ | **1.1.2** | 2024 | REST | Simulation mode added |
614
+ | **1.1.1** | 2024 | REST | Dependency updates |
615
+ | **1.1.0** | 2024 | REST | Initial release |