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