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