span-panel-api 3.8.0b1__tar.gz → 3.8.0b2__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.8.0b1 → span_panel_api-3.8.0b2}/.gitignore +1 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/CHANGELOG.md +3 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/PKG-INFO +5 -2
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/README.md +3 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/pyproject.toml +10 -3
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/__init__.py +14 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/models.py +93 -1
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/mqtt/client.py +30 -4
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/protocol.py +16 -1
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/adapter_fidelity/harness.py +5 -5
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/adapter_fidelity/rules.py +0 -7
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/adapter_fidelity/rules_captured.py +10 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/adapter_fidelity/test_harness.py +47 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/panelbench_unvalued_by_both.json +2 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/reference_payloads/captures.py +8 -4
- span_panel_api-3.8.0b2/tests/reference_payloads/reidentify.py +207 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/reference_payloads/schema_one.py +37 -5
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_adoption.py +215 -1
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_mqtt_client_connection.py +33 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_mqtt_homie.py +17 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_public_api_unchanged.py +9 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_publish_outcome.py +19 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_reference_tree_values.py +25 -14
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_adapter.py +182 -5
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_charge_limit.py +359 -28
- span_panel_api-3.8.0b2/tests/test_schema_one_cleared_retained.py +253 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_control_refusal.py +18 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_devices.py +104 -47
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_panel.py +12 -10
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_snapshot.py +22 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_transport.py +114 -0
- span_panel_api-3.8.0b2/tests/test_schema_one_tree_export.py +2201 -0
- span_panel_api-3.8.0b2/tests/test_tree_export_protocol.py +23 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/LICENSE +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/_ssl.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/auth.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/exceptions.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/mqtt/control.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/adapter_fidelity/__init__.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/adapter_fidelity/test_capture_fidelity.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/adapter_fidelity/test_main32_fidelity.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/conftest.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/expected_failures.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/captures/LICENSE +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/captures/README.md +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/captures/SHA256SUMS +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/captures/r202639-a-tree-v1.json +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/captures/r202639-b-tree-v1.json +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/captures/r202639-c-tree-v1.json +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/captures/r202639-d-tree-v1.json +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/captures/r202639-e-tree-v1.json +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/distribution-enclosure-simulator.LICENSE +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/main32-tree-v1.README.md +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/main32-tree-v1.json +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/main32_r202639-tree-v1.README.md +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/main32_r202639-tree-v1.json +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/other_model_tree.json +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/panelbench_wire.json +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/reference_payloads/README.md +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/reference_payloads/bootstrap.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/reference_payloads/synthetic_trees.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_absent_readings_are_not_zero.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_auth_redaction.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_ca_pinning.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_capture_replay.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_captures_unchanged.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_circuit_fed_battery_captures.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_control_interceptor.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_declared_unvalued_captures.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_evse_lock_captures.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_evse_lock_control.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_expected_failures_registry.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_feeds_role_captures.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_homie_schema_hash.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_https_transport.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_leaf_name_mismatch.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_packaging.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_panel_properties_captures.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_plaintext_warning.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_positions_captures.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_redispatch_on_reconnect.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_register_passphrase_unavailable.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_register_rate_limit.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_register_rate_limit_companions.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_rest_transport_contract.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_fetch_transport_split.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_migration_delta.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_acceptance_companions.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_circuit_fed_battery.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_circuits.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_conformance.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_connection_health.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_declared_unvalued.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_device_display_name.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_discovery.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_evse_lock.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_extension.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_feeds_role.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_firmware.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_other_models.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_panel_properties.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_positions.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_shared_meters.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_sheddable.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_space_less_meters.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_undeclared_devices.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_shared_http_client.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_shared_meters_captures.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_snapshot_readiness_gate.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_space_less_meters_captures.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_ssl_context.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_synthetic_trees.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_v2_status_parser.py +0 -0
- {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/tls_fixtures.py +0 -0
|
@@ -14,7 +14,9 @@ Snapshot fields that held a fabricated constant can now be unknown, and with `sp
|
|
|
14
14
|
### Added
|
|
15
15
|
|
|
16
16
|
- **spanos3/r202639/03 batch 2: reads more of the data this firmware publishes.**
|
|
17
|
+
- **`SpanMqttClient.masked_tree()` returns the panel's device tree with identifiers and site details masked**, for a consumer's diagnostics; `None` on a flat panel.
|
|
17
18
|
- **`V2StatusInfo.hardware_version` reports the hardware version string from `GET /api/v2/status`.**
|
|
19
|
+
- **`SpanEvseSnapshot.charge_current_limit_minimum_a` and `charge_current_limit_maximum_a` report the range a SPAN Drive declares for its charge-current limit**, `None` where undeclared.
|
|
18
20
|
|
|
19
21
|
### Changed
|
|
20
22
|
|
|
@@ -37,6 +39,7 @@ Snapshot fields that held a fabricated constant can now be unknown, and with `sp
|
|
|
37
39
|
|
|
38
40
|
- **`V2HomieSchema.types_schema_hash` on data model 1.0 is the panel's published `deviceClassesSchemaHash`, or a hash of `deviceClasses` when none is published, where it was one constant for every such panel**; a flat panel's value is unchanged, and a
|
|
39
41
|
value stored from 3.6.x differs once on a data model 1.0 panel.
|
|
42
|
+
- **A property the panel clears reads as unpublished** on a parent/child panel, where it read as an empty value until the panel published it again.
|
|
40
43
|
|
|
41
44
|
## [3.6.2]
|
|
42
45
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 3.8.
|
|
3
|
+
Version: 3.8.0b2
|
|
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.4.
|
|
25
|
+
Requires-Dist: span-panel-api-schema-1>=1.4.0b4; extra == 'schema-1'
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
|
|
28
28
|
# SPAN Panel API
|
|
@@ -171,6 +171,9 @@ because a transport could reasonably offer one and not the other.
|
|
|
171
171
|
One further protocol, `SchemaAdapter`, is the bootstrap-to-parser contract rather than a consumer-facing one; it is what an adapter distribution implements and what discovery checks. Integration code programs against the protocols above, not against
|
|
172
172
|
transport-specific classes.
|
|
173
173
|
|
|
174
|
+
`TreeExportingAdapter` sits beside `SchemaAdapter` as an optional protocol, not one every adapter must implement: an adapter that offers `export_masked_tree()` lets `SpanMqttClient.masked_tree()` return the panel's device tree with serials, device ids and
|
|
175
|
+
site details masked, and `None` otherwise.
|
|
176
|
+
|
|
174
177
|
### Snapshots
|
|
175
178
|
|
|
176
179
|
All panel state is represented as immutable, frozen dataclasses:
|
|
@@ -144,6 +144,9 @@ because a transport could reasonably offer one and not the other.
|
|
|
144
144
|
One further protocol, `SchemaAdapter`, is the bootstrap-to-parser contract rather than a consumer-facing one; it is what an adapter distribution implements and what discovery checks. Integration code programs against the protocols above, not against
|
|
145
145
|
transport-specific classes.
|
|
146
146
|
|
|
147
|
+
`TreeExportingAdapter` sits beside `SchemaAdapter` as an optional protocol, not one every adapter must implement: an adapter that offers `export_masked_tree()` lets `SpanMqttClient.masked_tree()` return the panel's device tree with serials, device ids and
|
|
148
|
+
site details masked, and `None` otherwise.
|
|
149
|
+
|
|
147
150
|
### Snapshots
|
|
148
151
|
|
|
149
152
|
All panel state is represented as immutable, frozen dataclasses:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "span-panel-api"
|
|
3
|
-
version = "3.8.
|
|
3
|
+
version = "3.8.0b2"
|
|
4
4
|
description = "A client library for SPAN Panel API"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "SpanPanel"}
|
|
@@ -71,7 +71,8 @@ schema-0 = ["span-panel-api-schema-0>=1.3.0b1"]
|
|
|
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
73
|
# bootstrap adds.
|
|
74
|
-
|
|
74
|
+
# Raised to 1.4.0b4 for 3.8.0b2: that adapter exports the masked device tree.
|
|
75
|
+
schema-1 = ["span-panel-api-schema-1>=1.4.0b4"]
|
|
75
76
|
|
|
76
77
|
[project.urls]
|
|
77
78
|
Homepage = "https://github.com/SpanPanel/span-panel-api"
|
|
@@ -95,7 +96,7 @@ dev = [
|
|
|
95
96
|
# known. Dependabot raises the bump on its own; the bump PR re-runs
|
|
96
97
|
# `scripts/capture_parent_child_reference.py` and the suite then says whether
|
|
97
98
|
# the wire moved.
|
|
98
|
-
"ebus-panel-sim==0.
|
|
99
|
+
"ebus-panel-sim==0.9.0",
|
|
99
100
|
"pytest>=9.0.2",
|
|
100
101
|
"pytest-asyncio>=1.3.0",
|
|
101
102
|
"pytest-cov",
|
|
@@ -255,6 +256,12 @@ explicit_package_bases = true
|
|
|
255
256
|
# `tests/` on `sys.path`; the support packages the mypy hook checks resolve the same
|
|
256
257
|
# way here.
|
|
257
258
|
mypy_path = "src:tests"
|
|
259
|
+
# paho ships `py.typed` but leaves its topic matcher unannotated, so every call
|
|
260
|
+
# into it would be an untyped call. The schema-1 route table resolves what the
|
|
261
|
+
# matcher yields through a typed dict at once, which keeps its `Any` from going
|
|
262
|
+
# further; `warn_return_any` does not police that for you (a union or a
|
|
263
|
+
# `yield from` passes it), so keep it to that shape.
|
|
264
|
+
untyped_calls_exclude = ["paho.mqtt.matcher"]
|
|
258
265
|
# Excluded by name for the reason `[tool.ruff]` gives above: the capture script is
|
|
259
266
|
# checked like the library, and the hand-run tools written before that was expected
|
|
260
267
|
# are not.
|
|
@@ -53,6 +53,9 @@ from .models import (
|
|
|
53
53
|
FeedsRole,
|
|
54
54
|
FieldMetadata,
|
|
55
55
|
HomieSchemaTypes,
|
|
56
|
+
MaskedTree,
|
|
57
|
+
MaskedTreeDevice,
|
|
58
|
+
MaskedTreeMetadata,
|
|
56
59
|
PassphraseRotation,
|
|
57
60
|
ProtectionFunction,
|
|
58
61
|
SpanBatterySnapshot,
|
|
@@ -62,6 +65,7 @@ from .models import (
|
|
|
62
65
|
SpanPanelSnapshot,
|
|
63
66
|
SpanPcsSnapshot,
|
|
64
67
|
SpanPVSnapshot,
|
|
68
|
+
UndescribedDevice,
|
|
65
69
|
V2AuthResponse,
|
|
66
70
|
V2HomieSchema,
|
|
67
71
|
V2StatusInfo,
|
|
@@ -95,6 +99,7 @@ from .protocol import (
|
|
|
95
99
|
PanelControlProtocol,
|
|
96
100
|
SpanPanelClientProtocol,
|
|
97
101
|
StreamingCapableProtocol,
|
|
102
|
+
TreeExportingAdapter,
|
|
98
103
|
)
|
|
99
104
|
|
|
100
105
|
__version__ = _pkg_version("span-panel-api")
|
|
@@ -147,6 +152,15 @@ __all__ = [ # noqa: RUF022
|
|
|
147
152
|
"ControlTarget",
|
|
148
153
|
"ExtensionProperty",
|
|
149
154
|
"ExtensionSubject",
|
|
155
|
+
# Added 2026-10-31: the masked device tree a consumer forwards in diagnostics,
|
|
156
|
+
# and the optional protocol an adapter offers it through. Deliberately not a
|
|
157
|
+
# SchemaAdapter member: the protocol derives its required set from itself, so a
|
|
158
|
+
# member there would invalidate every built adapter wheel.
|
|
159
|
+
"MaskedTree",
|
|
160
|
+
"MaskedTreeDevice",
|
|
161
|
+
"MaskedTreeMetadata",
|
|
162
|
+
"UndescribedDevice",
|
|
163
|
+
"TreeExportingAdapter",
|
|
150
164
|
# Snapshots
|
|
151
165
|
"SpanBatterySnapshot",
|
|
152
166
|
"SpanCircuitSnapshot",
|
|
@@ -12,7 +12,7 @@ from __future__ import annotations
|
|
|
12
12
|
from collections.abc import Mapping
|
|
13
13
|
from dataclasses import dataclass, field
|
|
14
14
|
import re
|
|
15
|
-
from typing import Final, Literal
|
|
15
|
+
from typing import Final, Literal, NotRequired, TypedDict
|
|
16
16
|
|
|
17
17
|
# Homie schema type: {type_name: {property_name: {attribute: value}}}
|
|
18
18
|
# Values are heterogeneous JSON (str, int, bool, nested dicts).
|
|
@@ -465,6 +465,36 @@ class SpanEvseSnapshot:
|
|
|
465
465
|
set topic when it is not.
|
|
466
466
|
"""
|
|
467
467
|
|
|
468
|
+
charge_current_limit_minimum_a: int | None = None
|
|
469
|
+
"""The lowest charge-current limit the charger declares a user may set, in amps. v1.0 only.
|
|
470
|
+
|
|
471
|
+
The minimum of the `<min>:<max>` range the limit's declaration states in
|
|
472
|
+
`$format`, from the same resolution as `charge_current_limit_a`; firmware
|
|
473
|
+
r202639 declares `6:<commissioned maximum>`. A write below it is refused
|
|
474
|
+
rather than published, so a control offers nothing under it.
|
|
475
|
+
|
|
476
|
+
A declaration, not a reading: it is reported as soon as the charger
|
|
477
|
+
describes itself, and whether or not the limit is settable, which
|
|
478
|
+
`charge_current_limit_settable` answers. `None` where the charger declares
|
|
479
|
+
no limit, no `$format`, or the format leaves this side open, and where the
|
|
480
|
+
format cannot be read as an integer range (not `integer`, not `<min>:<max>`,
|
|
481
|
+
a bound that is not a plain integer, or a minimum above the maximum). `None`
|
|
482
|
+
on the flat schema, which declares no range.
|
|
483
|
+
"""
|
|
484
|
+
|
|
485
|
+
charge_current_limit_maximum_a: int | None = None
|
|
486
|
+
"""The highest charge-current limit the charger declares a user may set, in amps. v1.0 only.
|
|
487
|
+
|
|
488
|
+
The maximum of the same declared range, `None` on the same terms. A write
|
|
489
|
+
above it is refused rather than published.
|
|
490
|
+
|
|
491
|
+
**Not `charge_current_ceiling_a`.** That is the commissioned ceiling, a
|
|
492
|
+
published value; this is the declared range, part of the limit's
|
|
493
|
+
description. Every r202639 capture declares a maximum equal to the ceiling
|
|
494
|
+
it publishes, but they are separate facts, and a write must satisfy both: a
|
|
495
|
+
control's upper bound is the lower of the two where both are present.
|
|
496
|
+
"""
|
|
497
|
+
|
|
468
498
|
lock_control: ControlTarget | None = None
|
|
469
499
|
"""Where the charger's connector lock is commanded, and which property reports it. v1.0 only.
|
|
470
500
|
|
|
@@ -1506,3 +1536,65 @@ class SpanPanelSnapshot:
|
|
|
1506
1536
|
the feeding device is another panel with its own tree, not a child of this
|
|
1507
1537
|
one.
|
|
1508
1538
|
"""
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
class MaskedTreeMetadata(TypedDict):
|
|
1542
|
+
"""What produced a `MaskedTree` and whether it can be trusted to be whole.
|
|
1543
|
+
|
|
1544
|
+
`masked` is always `True`: the tree never carries a serial, a credential or
|
|
1545
|
+
an address, and the marker lets whoever reads a forwarded diagnostic see that
|
|
1546
|
+
without inspecting the values. `tree_complete` is `False` when the panel had
|
|
1547
|
+
not finished publishing its tree, so what follows is a partial reading.
|
|
1548
|
+
"""
|
|
1549
|
+
|
|
1550
|
+
schema: Literal["tree-v1"]
|
|
1551
|
+
masked: Literal[True]
|
|
1552
|
+
producer: str
|
|
1553
|
+
tree_complete: bool
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
class MaskedTreeDevice(TypedDict):
|
|
1557
|
+
"""One described device in a `MaskedTree`, as a consumer forwards it in diagnostics.
|
|
1558
|
+
|
|
1559
|
+
Values are keyed `"<node>/<property>"` and kept as the wire strings the
|
|
1560
|
+
panel published, masked where they identify the device or the site.
|
|
1561
|
+
`numeric_properties` holds the values of declared `integer` and `float`
|
|
1562
|
+
properties, `properties` the values of every other declared property,
|
|
1563
|
+
`undeclared_properties` values the panel published that its own description
|
|
1564
|
+
does not declare, and `targets` its `$target` values. A declared property
|
|
1565
|
+
with no value has no key: never `None`, and never a `""` the panel did not
|
|
1566
|
+
send, because a placeholder would read as a reading. A `""` the panel did
|
|
1567
|
+
publish is kept as `""`.
|
|
1568
|
+
"""
|
|
1569
|
+
|
|
1570
|
+
description: dict[str, object]
|
|
1571
|
+
properties: dict[str, str]
|
|
1572
|
+
numeric_properties: dict[str, str]
|
|
1573
|
+
targets: dict[str, str]
|
|
1574
|
+
undeclared_properties: dict[str, str]
|
|
1575
|
+
state: NotRequired[str]
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
class UndescribedDevice(TypedDict):
|
|
1579
|
+
"""A device the panel published values for but never described.
|
|
1580
|
+
|
|
1581
|
+
With no `$description` there is nothing to say which node a property belongs
|
|
1582
|
+
to, so `values` keeps each as `"<node>/<property>"` -> wire string, unsplit.
|
|
1583
|
+
"""
|
|
1584
|
+
|
|
1585
|
+
values: dict[str, str]
|
|
1586
|
+
targets: dict[str, str]
|
|
1587
|
+
state: NotRequired[str]
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
class MaskedTree(TypedDict):
|
|
1591
|
+
"""The masked device tree a consumer forwards in diagnostics.
|
|
1592
|
+
|
|
1593
|
+
Produced by an adapter that offers `TreeExportingAdapter`. Described devices
|
|
1594
|
+
and undescribed ones are kept apart so a reader never mistakes one for the
|
|
1595
|
+
other.
|
|
1596
|
+
"""
|
|
1597
|
+
|
|
1598
|
+
metadata: MaskedTreeMetadata
|
|
1599
|
+
devices: dict[str, MaskedTreeDevice]
|
|
1600
|
+
undescribed_devices: dict[str, UndescribedDevice]
|
|
@@ -36,8 +36,16 @@ from ..exceptions import (
|
|
|
36
36
|
SpanPanelTLSVerificationError,
|
|
37
37
|
SpanPanelValidationError,
|
|
38
38
|
)
|
|
39
|
-
from ..models import
|
|
40
|
-
|
|
39
|
+
from ..models import (
|
|
40
|
+
AdoptedProperty,
|
|
41
|
+
ControlTarget,
|
|
42
|
+
FieldMetadata,
|
|
43
|
+
HomieSchemaTypes,
|
|
44
|
+
MaskedTree,
|
|
45
|
+
SpanPanelSnapshot,
|
|
46
|
+
V2HomieSchema,
|
|
47
|
+
)
|
|
48
|
+
from ..protocol import PanelCapability, SchemaAdapter, TreeExportingAdapter
|
|
41
49
|
from .connection import AsyncMqttBridge
|
|
42
50
|
from .const import MQTT_READY_TIMEOUT_S
|
|
43
51
|
from .control import ControlCommand, ControlDeadlines, ControlInterceptor, PublishOutcome, PublishState
|
|
@@ -420,6 +428,18 @@ class SpanMqttClient:
|
|
|
420
428
|
return None
|
|
421
429
|
return adapter.build_field_metadata()
|
|
422
430
|
|
|
431
|
+
def masked_tree(self) -> MaskedTree | None:
|
|
432
|
+
"""The panel's device tree with serials, device ids and site details masked.
|
|
433
|
+
|
|
434
|
+
Computed from the adapter's current view at call time. None on a flat
|
|
435
|
+
panel, whose adapter does not export a tree, and before the root device
|
|
436
|
+
has described itself.
|
|
437
|
+
"""
|
|
438
|
+
adapter = self._adapter
|
|
439
|
+
if isinstance(adapter, TreeExportingAdapter):
|
|
440
|
+
return adapter.export_masked_tree()
|
|
441
|
+
return None
|
|
442
|
+
|
|
423
443
|
async def connect(self) -> None:
|
|
424
444
|
"""Connect to MQTT broker and wait for Homie device ready.
|
|
425
445
|
|
|
@@ -1079,10 +1099,16 @@ class SpanMqttClient:
|
|
|
1079
1099
|
self._unregister_property_observer = adapter.register_property_callback(self._on_property_value)
|
|
1080
1100
|
|
|
1081
1101
|
def _on_property_value(self, device_id: str, node_id: str, property_id: str, value: str | None) -> None:
|
|
1082
|
-
"""Record one property's value and resolve any write waiting for it.
|
|
1102
|
+
"""Record one property's value and resolve any write waiting for it.
|
|
1103
|
+
|
|
1104
|
+
`None` is a property with no value, such as one the panel cleared. Its
|
|
1105
|
+
last value is forgotten, so a write of that value is not taken for a
|
|
1106
|
+
no-op, and no write is confirmed by it.
|
|
1107
|
+
"""
|
|
1108
|
+
key = (device_id, node_id, property_id)
|
|
1083
1109
|
if value is None:
|
|
1110
|
+
self._observed_values.pop(key, None)
|
|
1084
1111
|
return
|
|
1085
|
-
key = (device_id, node_id, property_id)
|
|
1086
1112
|
self._observed_values[key] = value
|
|
1087
1113
|
for verification in list(self._verifications):
|
|
1088
1114
|
if verification.key == key and verification.expected == value and not verification.observed.done():
|
|
@@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
15
|
from ._ssl import LeafNameMismatch
|
|
16
16
|
from .exceptions import SpanPanelError
|
|
17
|
-
from .models import ControlTarget, FieldMetadata, SpanPanelSnapshot, V2HomieSchema
|
|
17
|
+
from .models import ControlTarget, FieldMetadata, MaskedTree, SpanPanelSnapshot, V2HomieSchema
|
|
18
18
|
from .mqtt.control import ControlInterceptor, PublishOutcome
|
|
19
19
|
|
|
20
20
|
|
|
@@ -386,3 +386,18 @@ class SchemaAdapter(Protocol):
|
|
|
386
386
|
`device_id` is the panel serial rather than being omitted. Filling it in
|
|
387
387
|
is what lets a consumer treat both schemas' streams as one shape.
|
|
388
388
|
"""
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@runtime_checkable
|
|
392
|
+
class TreeExportingAdapter(Protocol):
|
|
393
|
+
"""Adapter that can export the device tree it holds, masked for diagnostics.
|
|
394
|
+
|
|
395
|
+
A protocol of its own rather than a `SchemaAdapter` member: the required set
|
|
396
|
+
of that protocol is derived from itself, so a member added there would be
|
|
397
|
+
demanded of every built adapter and invalidate those already shipped. A
|
|
398
|
+
consumer asks `isinstance(adapter, TreeExportingAdapter)` and offers the
|
|
399
|
+
export only when it holds.
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
def export_masked_tree(self) -> MaskedTree | None:
|
|
403
|
+
"""The masked tree, or None when the adapter holds no tree yet."""
|
|
@@ -44,7 +44,7 @@ from pathlib import Path
|
|
|
44
44
|
import re
|
|
45
45
|
from typing import Final
|
|
46
46
|
|
|
47
|
-
from reference_payloads.schema_one import RetainedTopicTree, devices_from_tree
|
|
47
|
+
from reference_payloads.schema_one import RetainedTopicTree, devices_from_tree, retained_payload
|
|
48
48
|
from span_panel_api.models import (
|
|
49
49
|
ADOPTION_IDENTITY_NODE,
|
|
50
50
|
ADOPTION_TOPOLOGY_NODE,
|
|
@@ -344,7 +344,7 @@ def _frozen_clock() -> float:
|
|
|
344
344
|
|
|
345
345
|
|
|
346
346
|
def _replay(capture: _Capture) -> SchemaOneAdapter:
|
|
347
|
-
"""A fresh adapter fed the capture as retained messages."""
|
|
347
|
+
"""A fresh adapter fed the capture as retained messages, an empty value as `0x00` (`retained_payload`)."""
|
|
348
348
|
schema = V2HomieSchema(
|
|
349
349
|
firmware_version=capture.firmware,
|
|
350
350
|
types_schema_hash="sha256:capture",
|
|
@@ -358,7 +358,7 @@ def _replay(capture: _Capture) -> SchemaOneAdapter:
|
|
|
358
358
|
adapter.handle_message(f"{prefix}/$description", json.dumps(device.description))
|
|
359
359
|
adapter.handle_message(f"{prefix}/$state", "ready")
|
|
360
360
|
for topic in sorted(device.values):
|
|
361
|
-
adapter.handle_message(f"{prefix}/{topic}", device.values[topic])
|
|
361
|
+
adapter.handle_message(f"{prefix}/{topic}", retained_payload(device.values[topic]))
|
|
362
362
|
return adapter
|
|
363
363
|
|
|
364
364
|
|
|
@@ -609,13 +609,13 @@ def _measure(capture: _Capture, instances: list[_Instance], baseline: Mapping[st
|
|
|
609
609
|
# as a later retained value does.
|
|
610
610
|
target = session if instance.raw is not None else _replay(capture)
|
|
611
611
|
for candidate in _candidates(instance.declaration, instance.raw, property_id, references):
|
|
612
|
-
target.handle_message(topic, candidate)
|
|
612
|
+
target.handle_message(topic, retained_payload(candidate))
|
|
613
613
|
leaves = _flatten(target)
|
|
614
614
|
moved = _moved(baseline, leaves)
|
|
615
615
|
instance.effect |= moved
|
|
616
616
|
instance.perturbed.append((candidate, {leaf: leaves[leaf] for leaf in moved if leaf in leaves}))
|
|
617
617
|
if instance.raw is not None:
|
|
618
|
-
session.handle_message(topic, instance.raw)
|
|
618
|
+
session.handle_message(topic, retained_payload(instance.raw))
|
|
619
619
|
unrestored = _moved(baseline, _flatten(session))
|
|
620
620
|
if unrestored:
|
|
621
621
|
raise AssertionError(f"republishing the captured values did not restore the session: {sorted(unrestored)[:5]}")
|
|
@@ -172,13 +172,6 @@ def _transforms() -> dict[RowKey, dict[str, Transform]]:
|
|
|
172
172
|
_DERIVED,
|
|
173
173
|
"devices.build_battery: compared in sign with the BESS meter where the firmware names no release build",
|
|
174
174
|
)
|
|
175
|
-
add(
|
|
176
|
-
"panel",
|
|
177
|
-
"power-flows/grid",
|
|
178
|
-
ANY_LEAF,
|
|
179
|
-
_DERIVED,
|
|
180
|
-
"panel.resolve_islanding_state: grid power decides on-grid only where no MID is published",
|
|
181
|
-
)
|
|
182
175
|
add("panel", "shed/policy", "panel.shed_policy", _LITERAL, panel_fields)
|
|
183
176
|
for policy_field in ("shed_policy_algorithm", "shed_soc_threshold_shed_percent", "shed_soc_threshold_release_percent"):
|
|
184
177
|
add("panel", "shed/policy", f"panel.{policy_field}", _DERIVED, "panel._shed_policy parses the policy document")
|
|
@@ -50,6 +50,16 @@ _DERIVATIONS: Final = (
|
|
|
50
50
|
"panel.resolve_dominant_power_source: with no MID, GRID is an elimination, since nothing else can form the grid",
|
|
51
51
|
values=("GRID",),
|
|
52
52
|
),
|
|
53
|
+
Derivation(
|
|
54
|
+
"panel.dsm_state",
|
|
55
|
+
"panel.resolve_islanding_state: with no MID, on-grid is the same elimination, since nothing can island the panel",
|
|
56
|
+
values=("DSM_ON_GRID",),
|
|
57
|
+
),
|
|
58
|
+
Derivation(
|
|
59
|
+
"panel.current_run_config",
|
|
60
|
+
"panel.resolve_islanding_state: with no MID, on-grid is the same elimination, since nothing can island the panel",
|
|
61
|
+
values=("PANEL_ON_GRID",),
|
|
62
|
+
),
|
|
53
63
|
Derivation(
|
|
54
64
|
"circuit.relay_state",
|
|
55
65
|
f"{_STR_SENTINEL}: circuits.build_circuit reads UNKNOWN where the device publishes no switch/relay",
|
|
@@ -22,6 +22,7 @@ from span_panel_api_schema_1 import adapter as adapter_module
|
|
|
22
22
|
from span_panel_api_schema_1.const import TYPE_CIRCUIT, TYPE_PV
|
|
23
23
|
from span_panel_api_schema_1.snapshot import build_snapshot
|
|
24
24
|
|
|
25
|
+
from . import harness
|
|
25
26
|
from .harness import FidelityReport, RowKey, run_cell
|
|
26
27
|
from .rules import MAIN32_RULES
|
|
27
28
|
|
|
@@ -192,3 +193,49 @@ def test_a_deferred_row_is_reported_apart_from_the_silent_drops(small_capture: P
|
|
|
192
193
|
report = run_cell(small_capture, replace(MAIN32_RULES, deferred={key: "planted for this test"}))
|
|
193
194
|
assert report.silently_dropped == ()
|
|
194
195
|
assert report.deferred == (key,)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_FED_BY = "connection/fed-by-device-id"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@pytest.fixture
|
|
202
|
+
def valued_empty_capture(small_capture: Path) -> Path:
|
|
203
|
+
"""The small capture with the upstream lugs publishing an empty `fed-by-device-id`.
|
|
204
|
+
|
|
205
|
+
In a `tree-v1` document an empty value is written decoded, as `""`, the way
|
|
206
|
+
the masked export writes one: a retained store never holds a zero-length
|
|
207
|
+
payload, so `""` there cannot be a clear.
|
|
208
|
+
"""
|
|
209
|
+
tree = _mapping(json.loads(small_capture.read_text(encoding="utf-8")))
|
|
210
|
+
devices = _mapping(tree["devices"])
|
|
211
|
+
upstream = next(
|
|
212
|
+
device_id
|
|
213
|
+
for device_id, entry in devices.items()
|
|
214
|
+
if _mapping(_mapping(entry)["properties"]).get("info/direction") == "UPSTREAM"
|
|
215
|
+
)
|
|
216
|
+
record = _mapping(devices[upstream])
|
|
217
|
+
record["properties"] = {**_mapping(record["properties"]), _FED_BY: ""}
|
|
218
|
+
devices[upstream] = record
|
|
219
|
+
path = small_capture.with_name("valued-empty-tree-v1.json")
|
|
220
|
+
path.write_text(json.dumps({**tree, "devices": devices}), encoding="utf-8")
|
|
221
|
+
return path
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_an_empty_value_is_measured_as_published(valued_empty_capture: Path) -> None:
|
|
225
|
+
"""Published empty, nothing feeds the lugs: they are the service entrance, not unknown.
|
|
226
|
+
|
|
227
|
+
Replayed as a zero-length payload, the value would be a clear and read as unpublished.
|
|
228
|
+
"""
|
|
229
|
+
leaves = harness._flatten(harness._replay(harness._load(valued_empty_capture)))
|
|
230
|
+
|
|
231
|
+
assert leaves["panel.lugs_at_service_entrance"] is True
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_republishing_an_empty_value_restores_the_session(valued_empty_capture: Path) -> None:
|
|
235
|
+
"""Each perturbed property is set back to its captured value; an empty one must go back as a value.
|
|
236
|
+
|
|
237
|
+
`run_cell` raises when the session does not end equal to the baseline.
|
|
238
|
+
"""
|
|
239
|
+
report = run_cell(valued_empty_capture, MAIN32_RULES)
|
|
240
|
+
|
|
241
|
+
assert report.declared_property_instances > 0
|
{span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/panelbench_unvalued_by_both.json
RENAMED
|
@@ -80,6 +80,8 @@
|
|
|
80
80
|
"energy.ebus.device.circuit::kitchen Lights connection/feeds-device-id",
|
|
81
81
|
"energy.ebus.device.circuit::kitchen Lights connection/feeds-device-status",
|
|
82
82
|
"energy.ebus.device.circuit::kitchen Lights connection/feeds-device-type",
|
|
83
|
+
"energy.ebus.device.evse::SPAN Drive - Driveway config/user-max-charge-current",
|
|
84
|
+
"energy.ebus.device.evse::SPAN Drive - Garage config/user-max-charge-current",
|
|
83
85
|
"energy.ebus.device.lugs::Downstream lugs connection/fed-by-device-id",
|
|
84
86
|
"energy.ebus.device.lugs::Downstream lugs connection/fed-by-device-status",
|
|
85
87
|
"energy.ebus.device.lugs::Downstream lugs connection/fed-by-device-type",
|
|
@@ -130,14 +130,18 @@ def capture_tree(stem: str) -> CaptureTree:
|
|
|
130
130
|
|
|
131
131
|
def _parse(path: Path) -> CaptureTree:
|
|
132
132
|
raw: object = json.loads(path.read_text(encoding="utf-8"))
|
|
133
|
-
|
|
133
|
+
return capture_tree_from_document(_json_object(raw, path.name), path.name)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def capture_tree_from_document(document: Mapping[str, object], where: str) -> CaptureTree:
|
|
137
|
+
"""Parse a `tree-v1` document already read from JSON; `where` names it in errors."""
|
|
134
138
|
devices = {
|
|
135
|
-
device_id: _device(device_id, body, f"{
|
|
136
|
-
for device_id, body in _json_object(document["devices"], f"{
|
|
139
|
+
device_id: _device(device_id, body, f"{where}: {device_id}")
|
|
140
|
+
for device_id, body in _json_object(document["devices"], f"{where}: devices").items()
|
|
137
141
|
}
|
|
138
142
|
roots = [device_id for device_id, device in devices.items() if device.device_type == _TYPE_PANEL]
|
|
139
143
|
if len(roots) != 1:
|
|
140
|
-
raise ValueError(f"{
|
|
144
|
+
raise ValueError(f"{where} holds {len(roots)} panels, not one")
|
|
141
145
|
return CaptureTree(devices=MappingProxyType(devices), root_id=roots[0])
|
|
142
146
|
|
|
143
147
|
|