span-panel-api 3.7.0b1__tar.gz → 3.8.0b1__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.7.0b1 → span_panel_api-3.8.0b1}/CHANGELOG.md +5 -1
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/PKG-INFO +2 -2
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/pyproject.toml +4 -2
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/__init__.py +24 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/auth.py +44 -13
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/exceptions.py +19 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/models.py +183 -5
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/mqtt/client.py +40 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/mqtt/control.py +1 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/protocol.py +13 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/adapter_fidelity/harness.py +11 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/adapter_fidelity/rules.py +142 -6
- span_panel_api-3.8.0b1/tests/adapter_fidelity/rules_captured.py +75 -0
- span_panel_api-3.8.0b1/tests/adapter_fidelity/test_capture_fidelity.py +153 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/adapter_fidelity/test_main32_fidelity.py +13 -4
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/conftest.py +55 -1
- span_panel_api-3.8.0b1/tests/expected_failures.py +48 -0
- span_panel_api-3.8.0b1/tests/fixtures/captures/README.md +17 -0
- span_panel_api-3.8.0b1/tests/fixtures/captures/SHA256SUMS +6 -0
- span_panel_api-3.8.0b1/tests/fixtures/captures/r202639-a-tree-v1.json +1048 -0
- span_panel_api-3.8.0b1/tests/fixtures/captures/r202639-b-tree-v1.json +4039 -0
- span_panel_api-3.8.0b1/tests/fixtures/captures/r202639-c-tree-v1.json +1863 -0
- span_panel_api-3.8.0b1/tests/fixtures/captures/r202639-d-tree-v1.json +2372 -0
- span_panel_api-3.8.0b1/tests/fixtures/captures/r202639-e-tree-v1.json +10577 -0
- span_panel_api-3.8.0b1/tests/fixtures/distribution-enclosure-simulator.LICENSE +21 -0
- span_panel_api-3.8.0b1/tests/fixtures/other_model_tree.json +276 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/reference_payloads/README.md +6 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/reference_payloads/__init__.py +10 -0
- span_panel_api-3.8.0b1/tests/reference_payloads/captures.py +249 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/reference_payloads/schema_one.py +61 -2
- span_panel_api-3.8.0b1/tests/reference_payloads/synthetic_trees.py +457 -0
- span_panel_api-3.8.0b1/tests/test_capture_replay.py +166 -0
- span_panel_api-3.8.0b1/tests/test_captures_unchanged.py +53 -0
- span_panel_api-3.8.0b1/tests/test_circuit_fed_battery_captures.py +21 -0
- span_panel_api-3.8.0b1/tests/test_declared_unvalued_captures.py +126 -0
- span_panel_api-3.8.0b1/tests/test_evse_lock_captures.py +59 -0
- span_panel_api-3.8.0b1/tests/test_evse_lock_control.py +112 -0
- span_panel_api-3.8.0b1/tests/test_expected_failures_registry.py +89 -0
- span_panel_api-3.8.0b1/tests/test_feeds_role_captures.py +30 -0
- span_panel_api-3.8.0b1/tests/test_panel_properties_captures.py +87 -0
- span_panel_api-3.8.0b1/tests/test_positions_captures.py +107 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_protocol_conformance.py +5 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_public_api_unchanged.py +21 -0
- span_panel_api-3.8.0b1/tests/test_register_rate_limit.py +87 -0
- span_panel_api-3.8.0b1/tests/test_register_rate_limit_companions.py +92 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_migration_delta.py +6 -0
- span_panel_api-3.8.0b1/tests/test_schema_one_acceptance_companions.py +57 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_adapter.py +1 -1
- span_panel_api-3.8.0b1/tests/test_schema_one_circuit_fed_battery.py +61 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_circuits.py +1 -1
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_conformance.py +37 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_connection_health.py +1 -1
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_control_refusal.py +2 -2
- span_panel_api-3.8.0b1/tests/test_schema_one_declared_unvalued.py +125 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_discovery.py +64 -1
- span_panel_api-3.8.0b1/tests/test_schema_one_evse_lock.py +57 -0
- span_panel_api-3.8.0b1/tests/test_schema_one_feeds_role.py +65 -0
- span_panel_api-3.8.0b1/tests/test_schema_one_other_models.py +589 -0
- span_panel_api-3.8.0b1/tests/test_schema_one_panel_properties.py +168 -0
- span_panel_api-3.8.0b1/tests/test_schema_one_positions.py +125 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_service_entrance.py +28 -4
- span_panel_api-3.8.0b1/tests/test_schema_one_shared_meters.py +115 -0
- span_panel_api-3.8.0b1/tests/test_schema_one_space_less_meters.py +175 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_undeclared_devices.py +1 -1
- span_panel_api-3.8.0b1/tests/test_shared_meters_captures.py +60 -0
- span_panel_api-3.8.0b1/tests/test_space_less_meters_captures.py +64 -0
- span_panel_api-3.8.0b1/tests/test_synthetic_trees.py +295 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/.gitignore +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/LICENSE +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/README.md +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/_ssl.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/adapter_fidelity/__init__.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/adapter_fidelity/test_harness.py +0 -0
- /span_panel_api-3.7.0b1/tests/fixtures/distribution-enclosure-simulator.LICENSE → /span_panel_api-3.8.0b1/tests/fixtures/captures/LICENSE +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/fixtures/main32-tree-v1.README.md +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/fixtures/main32-tree-v1.json +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/fixtures/main32_r202639-tree-v1.README.md +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/fixtures/main32_r202639-tree-v1.json +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/fixtures/panelbench_wire.json +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/reference_payloads/bootstrap.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_absent_readings_are_not_zero.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_adoption.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_auth_redaction.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_ca_pinning.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_control_interceptor.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_homie_schema_hash.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_https_transport.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_leaf_name_mismatch.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_mqtt_homie.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_packaging.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_plaintext_warning.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_publish_outcome.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_redispatch_on_reconnect.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_reference_tree_values.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_register_passphrase_unavailable.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_rest_transport_contract.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_fetch_transport_split.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_charge_limit.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_device_display_name.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_devices.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_extension.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_firmware.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_panel.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_sheddable.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_snapshot.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_shared_http_client.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_snapshot_readiness_gate.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_ssl_context.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/test_v2_status_parser.py +0 -0
- {span_panel_api-3.7.0b1 → span_panel_api-3.8.0b1}/tests/tls_fixtures.py +0 -0
|
@@ -13,6 +13,7 @@ Snapshot fields that held a fabricated constant can now be unknown, and with `sp
|
|
|
13
13
|
|
|
14
14
|
### Added
|
|
15
15
|
|
|
16
|
+
- **spanos3/r202639/03 batch 2: reads more of the data this firmware publishes.**
|
|
16
17
|
- **`V2StatusInfo.hardware_version` reports the hardware version string from `GET /api/v2/status`.**
|
|
17
18
|
|
|
18
19
|
### Changed
|
|
@@ -22,7 +23,10 @@ Snapshot fields that held a fabricated constant can now be unknown, and with `sp
|
|
|
22
23
|
- **BREAKING FOR CONSUMERS: `SpanPanelSnapshot.uptime_s` becomes `int | None`**, and with those parsers it is `None` on a parent/child panel and before a flat panel first reports ready, where it was a constant `0`.
|
|
23
24
|
- **BREAKING FOR CONSUMERS: `SpanCircuitSnapshot.instant_power_update_time_s` and `energy_accum_update_time_s` become `int | None` and default to `None`**, and with those parsers they are `None` until the reading has arrived and always on a parent/child
|
|
24
25
|
panel, where they were `0`.
|
|
25
|
-
- **
|
|
26
|
+
- **BREAKING FOR CONSUMERS: `SpanCircuitSnapshot.is_240v` becomes `bool | None`**, and with `span-panel-api-schema-1` 1.4.0 or newer it is `None` where the pole count is unpublished.
|
|
27
|
+
- **BREAKING FOR CONSUMERS: `SpanPanelSnapshot.lugs_at_service_entrance` becomes `bool | None`**, and with `span-panel-api-schema-1` 1.4.0 or newer it is `None` while the upstream lugs declare their feed but leave it unpublished.
|
|
28
|
+
- **The `schema-0` extra requires `span-panel-api-schema-0` 1.3.0 or newer and the `schema-1` extra requires `span-panel-api-schema-1` 1.4.0 or newer**, the adapters that report these values as unknown; schema-1 1.4.0 also fills the snapshot fields this
|
|
29
|
+
release adds.
|
|
26
30
|
|
|
27
31
|
### Removed
|
|
28
32
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.8.0b1
|
|
4
4
|
Summary: A client library for SPAN Panel API
|
|
5
5
|
Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
|
|
6
6
|
Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
|
|
@@ -22,7 +22,7 @@ Requires-Dist: pyyaml>=6.0.0
|
|
|
22
22
|
Provides-Extra: schema-0
|
|
23
23
|
Requires-Dist: span-panel-api-schema-0>=1.3.0b1; extra == 'schema-0'
|
|
24
24
|
Provides-Extra: schema-1
|
|
25
|
-
Requires-Dist: span-panel-api-schema-1>=1.
|
|
25
|
+
Requires-Dist: span-panel-api-schema-1>=1.4.0b1; extra == 'schema-1'
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
|
|
28
28
|
# SPAN Panel API
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "span-panel-api"
|
|
3
|
-
version = "3.
|
|
3
|
+
version = "3.8.0b1"
|
|
4
4
|
description = "A client library for SPAN Panel API"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "SpanPanel"}
|
|
@@ -69,7 +69,9 @@ schema-0 = ["span-panel-api-schema-0>=1.3.0b1"]
|
|
|
69
69
|
# Raised to 1.2.2 for 3.6.2: the readiness gate every snapshot passes asks that
|
|
70
70
|
# adapter what is still missing, and 1.2.2 restarts its feed grace correctly.
|
|
71
71
|
# Raised to 1.3.0b1 for 3.7.0b1, for the same reason as schema-0's.
|
|
72
|
-
|
|
72
|
+
# Raised to 1.4.0b1 for 3.8.0b1: that adapter fills the snapshot fields this
|
|
73
|
+
# bootstrap adds.
|
|
74
|
+
schema-1 = ["span-panel-api-schema-1>=1.4.0b1"]
|
|
73
75
|
|
|
74
76
|
[project.urls]
|
|
75
77
|
Homepage = "https://github.com/SpanPanel/span-panel-api"
|
|
@@ -29,6 +29,7 @@ from .exceptions import (
|
|
|
29
29
|
SpanPanelError,
|
|
30
30
|
SpanPanelInsufficientPrivilegeError,
|
|
31
31
|
SpanPanelPassphraseUnavailableError,
|
|
32
|
+
SpanPanelRateLimitError,
|
|
32
33
|
SpanPanelSchemaVersionError,
|
|
33
34
|
SpanPanelServerError,
|
|
34
35
|
SpanPanelStaleDataError,
|
|
@@ -41,15 +42,19 @@ from .models import (
|
|
|
41
42
|
ADOPTION_IDENTITY_NODE,
|
|
42
43
|
ADOPTION_TOPOLOGY_NODE,
|
|
43
44
|
DISCOVERY_NAMESPACE,
|
|
45
|
+
FEEDS_ROLES,
|
|
46
|
+
PROTECTION_FUNCTIONS,
|
|
44
47
|
AdoptedDevice,
|
|
45
48
|
AdoptedProperty,
|
|
46
49
|
ControlTarget,
|
|
47
50
|
DiscoveredMetadata,
|
|
48
51
|
ExtensionProperty,
|
|
49
52
|
ExtensionSubject,
|
|
53
|
+
FeedsRole,
|
|
50
54
|
FieldMetadata,
|
|
51
55
|
HomieSchemaTypes,
|
|
52
56
|
PassphraseRotation,
|
|
57
|
+
ProtectionFunction,
|
|
53
58
|
SpanBatterySnapshot,
|
|
54
59
|
SpanCircuitSnapshot,
|
|
55
60
|
SpanEvseSnapshot,
|
|
@@ -61,6 +66,7 @@ from .models import (
|
|
|
61
66
|
V2HomieSchema,
|
|
62
67
|
V2StatusInfo,
|
|
63
68
|
is_discovery_path,
|
|
69
|
+
shared_meter_groups,
|
|
64
70
|
)
|
|
65
71
|
from .mqtt import (
|
|
66
72
|
ControlCommand,
|
|
@@ -84,6 +90,7 @@ from .protocol import (
|
|
|
84
90
|
CircuitControlProtocol,
|
|
85
91
|
ControlInterceptionProtocol,
|
|
86
92
|
EvseControlProtocol,
|
|
93
|
+
EvseLockControlProtocol,
|
|
87
94
|
PanelCapability,
|
|
88
95
|
PanelControlProtocol,
|
|
89
96
|
SpanPanelClientProtocol,
|
|
@@ -106,6 +113,9 @@ __all__ = [ # noqa: RUF022
|
|
|
106
113
|
# snapshot rather than by its arguments -- a device the adapter models
|
|
107
114
|
# produces no AdoptedDevice and so cannot be addressed through it.
|
|
108
115
|
"AdoptedControlProtocol",
|
|
116
|
+
# A charger's connector lock, authorised by `SpanEvseSnapshot.lock_control`.
|
|
117
|
+
# A protocol of its own so `EvseControlProtocol`'s implementers are unchanged.
|
|
118
|
+
"EvseLockControlProtocol",
|
|
109
119
|
# Added 2026-08-25 (3.1.0): one veto/observe point for every control
|
|
110
120
|
# command. A protocol of its own rather than a member on the four control
|
|
111
121
|
# protocols, which would break their implementers a second time in one
|
|
@@ -145,6 +155,16 @@ __all__ = [ # noqa: RUF022
|
|
|
145
155
|
"SpanPVSnapshot",
|
|
146
156
|
"SpanPanelSnapshot",
|
|
147
157
|
"SpanPcsSnapshot",
|
|
158
|
+
# What a circuit's `connection/feeds-role` declares, read into
|
|
159
|
+
# `SpanCircuitSnapshot.feeds_role`.
|
|
160
|
+
"FEEDS_ROLES",
|
|
161
|
+
"FeedsRole",
|
|
162
|
+
# What a circuit's `breaker/protection-functions` lists, read into
|
|
163
|
+
# `SpanCircuitSnapshot.protection_functions`.
|
|
164
|
+
"PROTECTION_FUNCTIONS",
|
|
165
|
+
"ProtectionFunction",
|
|
166
|
+
# The circuits one meter measures, from `SpanCircuitSnapshot.meter_shared_with`.
|
|
167
|
+
"shared_meter_groups",
|
|
148
168
|
# Factory
|
|
149
169
|
"create_span_client",
|
|
150
170
|
# Detection
|
|
@@ -216,6 +236,10 @@ __all__ = [ # noqa: RUF022
|
|
|
216
236
|
# keep their meaning; deliberately not a SpanPanelAuthError, because the
|
|
217
237
|
# passphrase the user supplied may be correct.
|
|
218
238
|
"SpanPanelPassphraseUnavailableError",
|
|
239
|
+
# Registration refused with HTTP 429, carrying the panel's `Retry-After` in
|
|
240
|
+
# seconds. A subclass of SpanPanelAPIError, so existing except clauses keep
|
|
241
|
+
# their meaning; not a SpanPanelAuthError, because no credential was judged.
|
|
242
|
+
"SpanPanelRateLimitError",
|
|
219
243
|
"SpanPanelServerError",
|
|
220
244
|
"SpanPanelStaleDataError",
|
|
221
245
|
# Added 2026-08-31 (3.4.0): a bootstrap REST call that failed verification
|
|
@@ -10,9 +10,12 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import asyncio
|
|
12
12
|
from collections.abc import Collection, Mapping
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from email.utils import parsedate_to_datetime
|
|
13
15
|
import hashlib
|
|
14
16
|
import json
|
|
15
17
|
import logging
|
|
18
|
+
import math
|
|
16
19
|
import ssl
|
|
17
20
|
import uuid
|
|
18
21
|
|
|
@@ -24,6 +27,7 @@ from .exceptions import (
|
|
|
24
27
|
SpanPanelAuthError,
|
|
25
28
|
SpanPanelInsufficientPrivilegeError,
|
|
26
29
|
SpanPanelPassphraseUnavailableError,
|
|
30
|
+
SpanPanelRateLimitError,
|
|
27
31
|
SpanPanelServerError,
|
|
28
32
|
)
|
|
29
33
|
from .models import HomieSchemaTypes, PassphraseRotation, V2AuthResponse, V2HomieSchema, V2StatusInfo
|
|
@@ -216,21 +220,37 @@ CA_CERT_MAX_ATTEMPTS = 5
|
|
|
216
220
|
CA_CERT_BACKOFF_S = 1.5
|
|
217
221
|
|
|
218
222
|
|
|
219
|
-
def
|
|
220
|
-
"""
|
|
223
|
+
def _retry_after_s(retry_after: str | None, now: datetime) -> float | None:
|
|
224
|
+
"""The wait a ``Retry-After`` header asks for, in seconds, or None if it asks for none.
|
|
221
225
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
226
|
+
Both forms RFC 9110 allows: delta-seconds, taken as given, and an HTTP-date,
|
|
227
|
+
taken as the seconds from `now` until then (0.0 once it has passed). A
|
|
228
|
+
negative, infinite or unparseable value is no answer, so a bad header can
|
|
229
|
+
never stall or skip a retry.
|
|
225
230
|
"""
|
|
226
|
-
fallback = backoff_s * (2.0 ** (attempt - 1))
|
|
227
231
|
if retry_after is None:
|
|
228
|
-
return
|
|
232
|
+
return None
|
|
229
233
|
try:
|
|
230
|
-
|
|
231
|
-
except
|
|
232
|
-
|
|
233
|
-
|
|
234
|
+
seconds = float(retry_after)
|
|
235
|
+
except ValueError:
|
|
236
|
+
try:
|
|
237
|
+
until = parsedate_to_datetime(retry_after)
|
|
238
|
+
except (TypeError, ValueError):
|
|
239
|
+
return None
|
|
240
|
+
if until.tzinfo is None:
|
|
241
|
+
return None
|
|
242
|
+
return max((until - now).total_seconds(), 0.0)
|
|
243
|
+
return seconds if math.isfinite(seconds) and seconds >= 0 else None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _retry_delay(retry_after: str | None, attempt: int, backoff_s: float) -> float:
|
|
247
|
+
"""Return how long to wait before retrying a rate-limited request.
|
|
248
|
+
|
|
249
|
+
Prefers the panel's ``Retry-After`` header and falls back to exponential
|
|
250
|
+
backoff where it gives no usable wait.
|
|
251
|
+
"""
|
|
252
|
+
parsed = _retry_after_s(retry_after, datetime.now(UTC))
|
|
253
|
+
return backoff_s * (2.0 ** (attempt - 1)) if parsed is None else parsed
|
|
234
254
|
|
|
235
255
|
|
|
236
256
|
async def register_v2(
|
|
@@ -276,6 +296,7 @@ async def register_v2(
|
|
|
276
296
|
Raises:
|
|
277
297
|
SpanPanelAuthError: Invalid passphrase or auth failure
|
|
278
298
|
SpanPanelPassphraseUnavailableError: The panel cannot read its own passphrase
|
|
299
|
+
SpanPanelRateLimitError: HTTP 429; the panel is limiting registrations. Not retried
|
|
279
300
|
SpanPanelServerError: The panel is not ready to register clients (any 5xx,
|
|
280
301
|
including the 503 it answers before its serial number is known); retryable
|
|
281
302
|
SpanPanelConnectionError: Cannot reach panel
|
|
@@ -314,6 +335,16 @@ async def register_v2(
|
|
|
314
335
|
status_code=reply.status_code,
|
|
315
336
|
)
|
|
316
337
|
|
|
338
|
+
if reply.status_code == HTTP_TOO_MANY_REQUESTS:
|
|
339
|
+
# Not retried: the panel limits registrations per client address, and a
|
|
340
|
+
# retry here would spend the caller's next attempt on its behalf.
|
|
341
|
+
_log_auth_failure(reply.endpoint, reply.response, sent)
|
|
342
|
+
raise SpanPanelRateLimitError(
|
|
343
|
+
f"Panel is rate-limiting registration (HTTP {reply.status_code})",
|
|
344
|
+
status_code=reply.status_code,
|
|
345
|
+
retry_after_s=_retry_after_s(reply.headers.get("retry-after"), datetime.now(UTC)),
|
|
346
|
+
)
|
|
347
|
+
|
|
317
348
|
if reply.status_code == 422 and _error_detail(reply.response) == REGISTRATION_UNAVAILABLE_DETAIL:
|
|
318
349
|
# Checked before the general 422 below, which means "credential not
|
|
319
350
|
# accepted". This one is the panel failing to read its own passphrase,
|
|
@@ -613,8 +644,8 @@ async def rotate_passphrase(
|
|
|
613
644
|
Raises:
|
|
614
645
|
SpanPanelInsufficientPrivilegeError: HTTP 403; the token is reduced-privilege
|
|
615
646
|
SpanPanelAuthError: HTTP 401 (token invalid or stale) or 412 (no bearer token)
|
|
616
|
-
SpanPanelServerError: HTTP 5xx. After a 500 the outcome is unknown:
|
|
617
|
-
passphrase may or may not have changed.
|
|
647
|
+
SpanPanelServerError: HTTP 5xx. After a 500 or a 504 the outcome is unknown:
|
|
648
|
+
the passphrase may or may not have changed.
|
|
618
649
|
SpanPanelConnectionError: Cannot reach panel
|
|
619
650
|
SpanPanelTimeoutError: Request timed out
|
|
620
651
|
SpanPanelAPIError: Unexpected response
|
|
@@ -68,6 +68,25 @@ class SpanPanelServerError(SpanPanelAPIError):
|
|
|
68
68
|
"""
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
class SpanPanelRateLimitError(SpanPanelAPIError):
|
|
72
|
+
"""The panel is limiting the client's requests and refused this one (HTTP 429).
|
|
73
|
+
|
|
74
|
+
Raised by ``register_v2``, which does not retry: the caller decides when to
|
|
75
|
+
try again. ``retry_after_s`` is the wait the panel asked for, in seconds,
|
|
76
|
+
from its ``Retry-After`` header: a delta-seconds value as given, an HTTP-date
|
|
77
|
+
as the seconds until that time. ``None`` when the header is absent or holds
|
|
78
|
+
neither.
|
|
79
|
+
|
|
80
|
+
A `SpanPanelAPIError`, so an existing clause catching that class still
|
|
81
|
+
catches this, and not a `SpanPanelAuthError`: the credential was never
|
|
82
|
+
judged.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, message: str, status_code: int | None = None, retry_after_s: float | None = None) -> None:
|
|
86
|
+
super().__init__(message, status_code)
|
|
87
|
+
self.retry_after_s = retry_after_s
|
|
88
|
+
|
|
89
|
+
|
|
71
90
|
class SpanPanelPassphraseUnavailableError(SpanPanelAPIError):
|
|
72
91
|
"""The panel cannot read its own passphrase, so it cannot issue broker credentials.
|
|
73
92
|
|
|
@@ -11,11 +11,25 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
from collections.abc import Mapping
|
|
13
13
|
from dataclasses import dataclass, field
|
|
14
|
+
import re
|
|
15
|
+
from typing import Final, Literal
|
|
14
16
|
|
|
15
17
|
# Homie schema type: {type_name: {property_name: {attribute: value}}}
|
|
16
18
|
# Values are heterogeneous JSON (str, int, bool, nested dicts).
|
|
17
19
|
type HomieSchemaTypes = dict[str, dict[str, object]]
|
|
18
20
|
|
|
21
|
+
type FeedsRole = Literal["LOADS", "SUBPANEL", "SOLAR", "STORAGE", "GENERATOR", "MIXED", "UNUSED"]
|
|
22
|
+
"""What a circuit feeds, as its `connection/feeds-role` declares it."""
|
|
23
|
+
|
|
24
|
+
FEEDS_ROLES: Final[tuple[FeedsRole, ...]] = ("LOADS", "SUBPANEL", "SOLAR", "STORAGE", "GENERATOR", "MIXED", "UNUSED")
|
|
25
|
+
"""Every `FeedsRole`, for reading a wire value into one."""
|
|
26
|
+
|
|
27
|
+
type ProtectionFunction = Literal["OVERCURRENT", "SHORT_CIRCUIT", "GROUND_FAULT", "ARC_FAULT"]
|
|
28
|
+
"""One protection a circuit's breaker provides, as `breaker/protection-functions` lists it."""
|
|
29
|
+
|
|
30
|
+
PROTECTION_FUNCTIONS: Final[tuple[ProtectionFunction, ...]] = ("OVERCURRENT", "SHORT_CIRCUIT", "GROUND_FAULT", "ARC_FAULT")
|
|
31
|
+
"""Every `ProtectionFunction`, for reading a wire value into one."""
|
|
32
|
+
|
|
19
33
|
|
|
20
34
|
@dataclass(frozen=True, slots=True)
|
|
21
35
|
class SpanCircuitSnapshot:
|
|
@@ -33,9 +47,9 @@ class SpanCircuitSnapshot:
|
|
|
33
47
|
# fabricated zero as a reset and books the whole counter as an offset
|
|
34
48
|
# (`SpanPanel/span#259`). A new circuit legitimately reads `0.0`, and the
|
|
35
49
|
# two must stay tellable apart.
|
|
36
|
-
instant_power_w: float | None # Positive = consumption
|
|
37
|
-
produced_energy_wh: float | None # Generation/backfeed (Wh)
|
|
38
|
-
consumed_energy_wh: float | None # Consumption (Wh)
|
|
50
|
+
instant_power_w: float | None # Positive = consumption; import where `measures_outside_panel`
|
|
51
|
+
produced_energy_wh: float | None # Generation/backfeed (Wh); export where `measures_outside_panel`
|
|
52
|
+
consumed_energy_wh: float | None # Consumption (Wh); import where `measures_outside_panel`
|
|
39
53
|
tabs: list[int]
|
|
40
54
|
priority: str # v1: MUST_HAVE | NICE_TO_HAVE | NON_ESSENTIAL | UNKNOWN
|
|
41
55
|
# v2: NEVER | SOC_THRESHOLD | OFF_GRID | UNKNOWN
|
|
@@ -44,7 +58,10 @@ class SpanCircuitSnapshot:
|
|
|
44
58
|
is_never_backup: bool # v1: Circuit.isNeverBackup | v2: circuit/never-backup
|
|
45
59
|
device_type: str = "circuit" # "circuit" | "pv" | "evse"
|
|
46
60
|
relative_position: str = "" # PV/EVSE: "IN_PANEL" | "UPSTREAM" | "DOWNSTREAM"
|
|
47
|
-
|
|
61
|
+
# From the breaker's pole count. `None` where the circuit publishes no pole
|
|
62
|
+
# count: a meter outside the panel has no breaker, and a breaker whose count
|
|
63
|
+
# has not arrived has not said.
|
|
64
|
+
is_240v: bool | None = False
|
|
48
65
|
current_a: float | None = None
|
|
49
66
|
breaker_rating_a: float | None = None
|
|
50
67
|
always_on: bool = False # v2 new: circuit/always-on
|
|
@@ -75,6 +92,53 @@ class SpanCircuitSnapshot:
|
|
|
75
92
|
pcs_managed: bool | None = None # v2: circuit pcs/managed
|
|
76
93
|
pcs_priority: int | None = None # v2: circuit pcs/priority
|
|
77
94
|
|
|
95
|
+
measures_outside_panel: bool = False
|
|
96
|
+
"""The device declares no breaker space: a meter outside the panel.
|
|
97
|
+
|
|
98
|
+
Power is import-positive (positive = flowing into the panel); consumed is
|
|
99
|
+
imported energy and produced is exported energy, so consumed minus produced
|
|
100
|
+
is net import. Read from the device's declaration, never from its values, so
|
|
101
|
+
a meter that has not reported yet is already outside the panel. Such a meter
|
|
102
|
+
has no relay, no breaker and no shed priority, and occupies no position.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
meter_shared_with: tuple[str, ...] | None = None
|
|
106
|
+
"""The other circuits this circuit's meter also measures, by circuit id.
|
|
107
|
+
|
|
108
|
+
From `meter/shared-with-device-ids`, without the circuit itself or an id
|
|
109
|
+
naming no circuit in the snapshot, ordered by `device_id_order`. `None`
|
|
110
|
+
where the circuit declares no such list or has not published it; `()` where
|
|
111
|
+
it lists no circuit the snapshot holds. The peers' readings are the same
|
|
112
|
+
meter's, so a consumer never adds them up; `shared_meter_groups` groups
|
|
113
|
+
them.
|
|
114
|
+
"""
|
|
115
|
+
relay_shared_with: tuple[str, ...] | None = None
|
|
116
|
+
"""The other circuits this circuit's relay also switches, by circuit id.
|
|
117
|
+
|
|
118
|
+
From `switch/shared-with-device-ids`, read as `meter_shared_with` is.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
nominal_voltage_v: float | None = None
|
|
122
|
+
"""The circuit's rated voltage, from `info/nominal-voltage`.
|
|
123
|
+
|
|
124
|
+
`None` where the circuit declares none or has not published it.
|
|
125
|
+
"""
|
|
126
|
+
protection_functions: tuple[ProtectionFunction, ...] | None = None
|
|
127
|
+
"""The protections the circuit's breaker provides, from `breaker/protection-functions`.
|
|
128
|
+
|
|
129
|
+
In published order, without any value outside `PROTECTION_FUNCTIONS`.
|
|
130
|
+
`None` where the breaker declares none or has not published them.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
feeds_role: FeedsRole | None = None
|
|
134
|
+
"""What the circuit feeds, from its `connection/feeds-role`.
|
|
135
|
+
|
|
136
|
+
`None` where the circuit declares no role, has not published it, or
|
|
137
|
+
publishes a value outside `FEEDS_ROLES`. Reported as declared and nothing
|
|
138
|
+
more: `device_type` stays as published, so a circuit whose role is solar is
|
|
139
|
+
still a circuit. The flat schema declares no roles.
|
|
140
|
+
"""
|
|
141
|
+
|
|
78
142
|
|
|
79
143
|
@dataclass(frozen=True, slots=True)
|
|
80
144
|
class SpanPVSnapshot:
|
|
@@ -401,6 +465,22 @@ class SpanEvseSnapshot:
|
|
|
401
465
|
set topic when it is not.
|
|
402
466
|
"""
|
|
403
467
|
|
|
468
|
+
lock_control: ControlTarget | None = None
|
|
469
|
+
"""Where the charger's connector lock is commanded, and which property reports it. v1.0 only.
|
|
470
|
+
|
|
471
|
+
Addresses `switch/lock-state` on the charger's own device, and is present
|
|
472
|
+
only where that declaration carries `$settable`: absence is refusal, so a
|
|
473
|
+
lock the charger reports but does not accept writes to has no target. The
|
|
474
|
+
value written is one of `lock_state_options`. `None` on the flat schema.
|
|
475
|
+
"""
|
|
476
|
+
|
|
477
|
+
lock_state_options: tuple[str, ...] | None = None
|
|
478
|
+
"""The values the charger's `switch/lock-state` declares in its `$format`. v1.0 only.
|
|
479
|
+
|
|
480
|
+
What a lock command may write: a value outside them is refused rather than
|
|
481
|
+
published. `None` where the charger declares no lock or no `$format`.
|
|
482
|
+
"""
|
|
483
|
+
|
|
404
484
|
connected: bool | None = None
|
|
405
485
|
"""The enclosure's view of the link to this charger, v1.0 only.
|
|
406
486
|
|
|
@@ -476,6 +556,20 @@ class SpanBatterySnapshot:
|
|
|
476
556
|
# it, and the migration guide warns against conflating them.
|
|
477
557
|
communication_state: str | None = None # v2: bess status/communication-state
|
|
478
558
|
|
|
559
|
+
# Whether the panel's device tree declares a battery, v1.0 only. True from the
|
|
560
|
+
# moment the battery describes itself, before any of its values arrive, so a
|
|
561
|
+
# battery whose state of charge is never published is still a battery. None
|
|
562
|
+
# on the flat schema, which has no battery device to declare and publishes
|
|
563
|
+
# its battery values whether or not one is installed.
|
|
564
|
+
present: bool | None = None
|
|
565
|
+
|
|
566
|
+
# The circuit whose `connection/feeds-device-id` names this battery, v1.0
|
|
567
|
+
# only. Where it is set and no device claims the battery through a
|
|
568
|
+
# `fed-by-*` record, `connected` is that circuit's `feeds-device-status`.
|
|
569
|
+
# None where no circuit feeds the battery, as when it sits ahead of the
|
|
570
|
+
# upstream lugs, and on the flat schema.
|
|
571
|
+
feed_circuit_id: str | None = None
|
|
572
|
+
|
|
479
573
|
|
|
480
574
|
@dataclass(frozen=True, slots=True)
|
|
481
575
|
class FieldMetadata:
|
|
@@ -540,6 +634,48 @@ reading a row can look it up without translating it.
|
|
|
540
634
|
_DISCOVERY_PREFIX = f"{DISCOVERY_NAMESPACE}."
|
|
541
635
|
|
|
542
636
|
|
|
637
|
+
def device_id_order(device_id: str) -> tuple[str | int, ...]:
|
|
638
|
+
"""Sort key for device ids under which digit runs compare as numbers.
|
|
639
|
+
|
|
640
|
+
`circuit-9` sorts before `circuit-10`. Splitting on a captured digit run
|
|
641
|
+
leaves the runs at the odd positions and the text between them at the even
|
|
642
|
+
ones, so every position holds the same type in every key.
|
|
643
|
+
"""
|
|
644
|
+
parts = re.split(r"(\d+)", device_id)
|
|
645
|
+
return tuple(int(part) if index % 2 else part for index, part in enumerate(parts))
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def shared_meter_groups(circuits: Mapping[str, SpanCircuitSnapshot]) -> dict[str, tuple[str, ...]]:
|
|
649
|
+
"""The circuits one meter measures, grouped and keyed by their first member.
|
|
650
|
+
|
|
651
|
+
A group holds every circuit linked through `meter_shared_with`, in
|
|
652
|
+
`device_id_order`, and its key is the first of them. A circuit that names no
|
|
653
|
+
known peer and is named by none is in no group. Only membership: no reading
|
|
654
|
+
is combined, because every member already reports the one meter's values.
|
|
655
|
+
"""
|
|
656
|
+
links: dict[str, set[str]] = {}
|
|
657
|
+
for circuit_id, circuit in circuits.items():
|
|
658
|
+
for peer in circuit.meter_shared_with or ():
|
|
659
|
+
if peer in circuits and peer != circuit_id:
|
|
660
|
+
links.setdefault(circuit_id, set()).add(peer)
|
|
661
|
+
links.setdefault(peer, set()).add(circuit_id)
|
|
662
|
+
groups: dict[str, tuple[str, ...]] = {}
|
|
663
|
+
seen: set[str] = set()
|
|
664
|
+
for start in sorted(links, key=device_id_order):
|
|
665
|
+
if start in seen:
|
|
666
|
+
continue
|
|
667
|
+
members = {start}
|
|
668
|
+
pending = [start]
|
|
669
|
+
while pending:
|
|
670
|
+
for peer in links[pending.pop()] - members:
|
|
671
|
+
members.add(peer)
|
|
672
|
+
pending.append(peer)
|
|
673
|
+
seen |= members
|
|
674
|
+
ordered = tuple(sorted(members, key=device_id_order))
|
|
675
|
+
groups[ordered[0]] = ordered
|
|
676
|
+
return groups
|
|
677
|
+
|
|
678
|
+
|
|
543
679
|
def discovery_path(device_type: str, node_id: str, property_id: str) -> str:
|
|
544
680
|
"""The namespaced field path for one declared-but-unaddressed property.
|
|
545
681
|
|
|
@@ -1135,6 +1271,45 @@ class SpanPanelSnapshot:
|
|
|
1135
1271
|
and `DeviceInfo(hw_version=...)` is the consumer's abbreviation, not ours.
|
|
1136
1272
|
"""
|
|
1137
1273
|
|
|
1274
|
+
busbar_current_a: float | None = None
|
|
1275
|
+
"""The panel's busbar current, from its `meter/busbar-current`. v1.0 only.
|
|
1276
|
+
|
|
1277
|
+
`None` where the panel declares none or has not published it.
|
|
1278
|
+
"""
|
|
1279
|
+
frequency_hz: float | None = None
|
|
1280
|
+
"""The line frequency, from the panel's `meter/frequency`. v1.0 only.
|
|
1281
|
+
|
|
1282
|
+
`None` where the panel declares none or has not published it.
|
|
1283
|
+
"""
|
|
1284
|
+
upstream_protection_rating_a: int | None = None
|
|
1285
|
+
"""The rating of the protection ahead of the upstream lugs, in amperes. v1.0 only.
|
|
1286
|
+
|
|
1287
|
+
From the upstream lugs' `connection/overcurrent-protection`; `None` where
|
|
1288
|
+
they declare none or have not published it.
|
|
1289
|
+
"""
|
|
1290
|
+
|
|
1291
|
+
first_position: int | None = None
|
|
1292
|
+
"""The panel's first breaker position. v1.0 only.
|
|
1293
|
+
|
|
1294
|
+
The reported model's range as the adapter's model table gives it, where
|
|
1295
|
+
the table knows the model, otherwise the lowest occupied space. An occupied
|
|
1296
|
+
space outside a known model's range does not move it: the adapter warns
|
|
1297
|
+
once for that panel instead. `None` on the flat schema and where neither is
|
|
1298
|
+
known. Together with `last_position` it says which positions exist,
|
|
1299
|
+
occupied or not; no circuit is made up for an empty one.
|
|
1300
|
+
"""
|
|
1301
|
+
last_position: int | None = None
|
|
1302
|
+
"""The panel's last breaker position. v1.0 only; see `first_position`."""
|
|
1303
|
+
|
|
1304
|
+
publishes_solar_roles: bool = False
|
|
1305
|
+
"""Whether any circuit declares `connection/feeds-role`.
|
|
1306
|
+
|
|
1307
|
+
The declaration decides, not the value: a circuit that declares the role
|
|
1308
|
+
before publishing it, or declares a role other than solar, still marks the
|
|
1309
|
+
panel as one that says where its solar is. `False` on the flat schema,
|
|
1310
|
+
which declares no roles.
|
|
1311
|
+
"""
|
|
1312
|
+
|
|
1138
1313
|
# `shed/policy`, v1.0 only: how the panel decides what to shed, and the two
|
|
1139
1314
|
# SoC thresholds that make its behaviour predictable. The wire carries one
|
|
1140
1315
|
# `json` document; these are the parsed answer plus the document itself.
|
|
@@ -1285,7 +1460,7 @@ class SpanPanelSnapshot:
|
|
|
1285
1460
|
apart — every limit in this capture is a legal `0.0`.
|
|
1286
1461
|
"""
|
|
1287
1462
|
|
|
1288
|
-
lugs_at_service_entrance: bool = True
|
|
1463
|
+
lugs_at_service_entrance: bool | None = True
|
|
1289
1464
|
"""Whether this enclosure's upstream lugs *are* the utility connection point.
|
|
1290
1465
|
|
|
1291
1466
|
`False` means something sits between the utility and the main lugs, so the
|
|
@@ -1316,6 +1491,9 @@ class SpanPanelSnapshot:
|
|
|
1316
1491
|
flat panel's lugs are its service entrance. schema_0 leaves it alone for that
|
|
1317
1492
|
reason.
|
|
1318
1493
|
|
|
1494
|
+
`None` while the lugs declare `connection/fed-by-device-id` and have not
|
|
1495
|
+
published it: the feed may yet name a device, so neither answer is known.
|
|
1496
|
+
|
|
1319
1497
|
A defaulted snapshot field rather than a `SchemaAdapter` member, for the
|
|
1320
1498
|
reason `adopted_devices` gives above: the protocol derives its required
|
|
1321
1499
|
members from itself, so a new member would be required of every adapter
|
|
@@ -48,6 +48,11 @@ if TYPE_CHECKING:
|
|
|
48
48
|
|
|
49
49
|
_LOGGER = logging.getLogger(__name__)
|
|
50
50
|
|
|
51
|
+
LOCK_STATE_LOCKED = "LOCKED"
|
|
52
|
+
"""The `switch/lock-state` value that locks a charger's connector, as the panel declares it."""
|
|
53
|
+
LOCK_STATE_UNLOCKED = "UNLOCKED"
|
|
54
|
+
"""The `switch/lock-state` value that unlocks it."""
|
|
55
|
+
|
|
51
56
|
# How long to wait for circuit name properties after device ready.
|
|
52
57
|
# Retained messages typically arrive within 1-2s, but allow headroom.
|
|
53
58
|
_CIRCUIT_NAMES_TIMEOUT_S = 10.0
|
|
@@ -949,6 +954,41 @@ class SpanMqttClient:
|
|
|
949
954
|
)
|
|
950
955
|
return await self._publish_control(target, payload, self._control_deadlines.evse_charge_limit)
|
|
951
956
|
|
|
957
|
+
# -- EvseLockControlProtocol -------------------------------------------
|
|
958
|
+
|
|
959
|
+
async def set_evse_lock(self, node_id: str, locked: bool) -> PublishOutcome:
|
|
960
|
+
"""Lock or unlock one commissioned charger's connector.
|
|
961
|
+
|
|
962
|
+
Args:
|
|
963
|
+
node_id: the key this charger has in `SpanPanelSnapshot.evse`
|
|
964
|
+
locked: True to lock, False to unlock
|
|
965
|
+
|
|
966
|
+
**The snapshot is the authorisation**, as for an adopted property: the
|
|
967
|
+
charger's `lock_control` is the topic, and it is set only where the
|
|
968
|
+
charger declares `switch/lock-state` settable. The value must be one the
|
|
969
|
+
declaration's `$format` lists, so a charger that spells its states
|
|
970
|
+
differently is refused rather than sent a word it does not know.
|
|
971
|
+
"""
|
|
972
|
+
value = LOCK_STATE_LOCKED if locked else LOCK_STATE_UNLOCKED
|
|
973
|
+
charger = self._require_adapter().build_snapshot().evse.get(node_id)
|
|
974
|
+
target = None if charger is None else charger.lock_control
|
|
975
|
+
if charger is None or target is None:
|
|
976
|
+
await self._refuse_control(
|
|
977
|
+
device_id=node_id,
|
|
978
|
+
value=value,
|
|
979
|
+
detail="no such control",
|
|
980
|
+
message=f"No settable lock on EVSE {node_id!r}",
|
|
981
|
+
)
|
|
982
|
+
if value not in (charger.lock_state_options or ()):
|
|
983
|
+
await self._refuse_control(
|
|
984
|
+
target=target,
|
|
985
|
+
device_id=target.device_id,
|
|
986
|
+
value=value,
|
|
987
|
+
detail="value not declared",
|
|
988
|
+
message=f"EVSE {node_id!r} does not declare the lock state {value}",
|
|
989
|
+
)
|
|
990
|
+
return await self._publish_control(target, value, self._control_deadlines.evse_lock)
|
|
991
|
+
|
|
952
992
|
# -- AdoptedControlProtocol --------------------------------------------
|
|
953
993
|
|
|
954
994
|
async def set_adopted_property(self, device_id: str, node_id: str, property_id: str, value: str) -> PublishOutcome:
|
|
@@ -124,6 +124,19 @@ class EvseControlProtocol(Protocol):
|
|
|
124
124
|
async def set_evse_charge_limit(self, node_id: str, amps: int) -> PublishOutcome: ...
|
|
125
125
|
|
|
126
126
|
|
|
127
|
+
@runtime_checkable
|
|
128
|
+
class EvseLockControlProtocol(Protocol):
|
|
129
|
+
"""Control protocol for a commissioned EV charger's connector lock.
|
|
130
|
+
|
|
131
|
+
A protocol of its own rather than a member on `EvseControlProtocol`, which
|
|
132
|
+
would break that protocol's implementers. The write is authorised by the
|
|
133
|
+
snapshot: only a charger whose `SpanEvseSnapshot.lock_control` is set can be
|
|
134
|
+
addressed, and only with a value its `lock_state_options` lists.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
async def set_evse_lock(self, node_id: str, locked: bool) -> PublishOutcome: ...
|
|
138
|
+
|
|
139
|
+
|
|
127
140
|
@runtime_checkable
|
|
128
141
|
class AdoptedControlProtocol(Protocol):
|
|
129
142
|
"""Control protocol for settable properties on a device nothing here models.
|
|
@@ -542,10 +542,21 @@ class _Instance:
|
|
|
542
542
|
deferred: bool = False
|
|
543
543
|
|
|
544
544
|
|
|
545
|
+
OUTSIDE_PANEL_ROLE: Final = "meter-outside-panel"
|
|
546
|
+
"""The role of a circuit-typed device that declares no breaker space: a meter outside the panel.
|
|
547
|
+
|
|
548
|
+
Its own role, because the library reads it in the opposite frame from a hosted
|
|
549
|
+
circuit (`circuits.build_circuit` and `SpanCircuitSnapshot.measures_outside_panel`),
|
|
550
|
+
so one row could not document both.
|
|
551
|
+
"""
|
|
552
|
+
|
|
553
|
+
|
|
545
554
|
def _role(capture: _Capture, device: _Device) -> str:
|
|
546
555
|
if device.device_id == capture.root_id:
|
|
547
556
|
return "panel"
|
|
548
557
|
short = device.device_type.removeprefix(DEVICE_TYPE_PREFIX)
|
|
558
|
+
if device.device_type == TYPE_CIRCUIT and "info/spaces" not in device.declared:
|
|
559
|
+
return OUTSIDE_PANEL_ROLE
|
|
549
560
|
if device.device_type.startswith(TYPE_LUGS):
|
|
550
561
|
direction = device.values.get("info/direction", "").strip().lower()
|
|
551
562
|
return f"lugs-{direction}" if direction else "lugs"
|