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.
Files changed (163) hide show
  1. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/.gitignore +1 -0
  2. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/CHANGELOG.md +3 -0
  3. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/PKG-INFO +5 -2
  4. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/README.md +3 -0
  5. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/pyproject.toml +10 -3
  6. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/__init__.py +14 -0
  7. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/models.py +93 -1
  8. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/mqtt/client.py +30 -4
  9. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/protocol.py +16 -1
  10. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/adapter_fidelity/harness.py +5 -5
  11. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/adapter_fidelity/rules.py +0 -7
  12. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/adapter_fidelity/rules_captured.py +10 -0
  13. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/adapter_fidelity/test_harness.py +47 -0
  14. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/panelbench_unvalued_by_both.json +2 -0
  15. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/reference_payloads/captures.py +8 -4
  16. span_panel_api-3.8.0b2/tests/reference_payloads/reidentify.py +207 -0
  17. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/reference_payloads/schema_one.py +37 -5
  18. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_adoption.py +215 -1
  19. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_mqtt_client_connection.py +33 -0
  20. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_mqtt_homie.py +17 -0
  21. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_public_api_unchanged.py +9 -0
  22. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_publish_outcome.py +19 -0
  23. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_reference_tree_values.py +25 -14
  24. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_adapter.py +182 -5
  25. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_charge_limit.py +359 -28
  26. span_panel_api-3.8.0b2/tests/test_schema_one_cleared_retained.py +253 -0
  27. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_control_refusal.py +18 -0
  28. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_devices.py +104 -47
  29. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_panel.py +12 -10
  30. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_snapshot.py +22 -0
  31. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_transport.py +114 -0
  32. span_panel_api-3.8.0b2/tests/test_schema_one_tree_export.py +2201 -0
  33. span_panel_api-3.8.0b2/tests/test_tree_export_protocol.py +23 -0
  34. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/LICENSE +0 -0
  35. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/_http.py +0 -0
  36. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/_ssl.py +0 -0
  37. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/adapters.py +0 -0
  38. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/auth.py +0 -0
  39. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/const.py +0 -0
  40. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/detection.py +0 -0
  41. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/dispatch.py +0 -0
  42. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/exceptions.py +0 -0
  43. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/factory.py +0 -0
  44. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/mqtt/__init__.py +0 -0
  45. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/mqtt/async_client.py +0 -0
  46. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/mqtt/connection.py +0 -0
  47. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/mqtt/const.py +0 -0
  48. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/mqtt/control.py +0 -0
  49. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/mqtt/models.py +0 -0
  50. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/phase_validation.py +0 -0
  51. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/py.typed +0 -0
  52. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/src/span_panel_api/schema_drift.py +0 -0
  53. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/adapter_fidelity/__init__.py +0 -0
  54. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/adapter_fidelity/test_capture_fidelity.py +0 -0
  55. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/adapter_fidelity/test_main32_fidelity.py +0 -0
  56. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/conftest.py +0 -0
  57. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/expected_failures.py +0 -0
  58. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/captures/LICENSE +0 -0
  59. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/captures/README.md +0 -0
  60. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/captures/SHA256SUMS +0 -0
  61. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/captures/r202639-a-tree-v1.json +0 -0
  62. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/captures/r202639-b-tree-v1.json +0 -0
  63. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/captures/r202639-c-tree-v1.json +0 -0
  64. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/captures/r202639-d-tree-v1.json +0 -0
  65. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/captures/r202639-e-tree-v1.json +0 -0
  66. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  67. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  68. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  69. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/distribution-enclosure-simulator.LICENSE +0 -0
  70. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/flat_wire.json +0 -0
  71. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/main32-tree-v1.README.md +0 -0
  72. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/main32-tree-v1.json +0 -0
  73. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/main32_r202639-tree-v1.README.md +0 -0
  74. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/main32_r202639-tree-v1.json +0 -0
  75. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/other_model_tree.json +0 -0
  76. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/panelbench_wire.json +0 -0
  77. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/v2/README.md +0 -0
  78. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/fixtures/v2/status.json +0 -0
  79. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/reference_payloads/README.md +0 -0
  80. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/reference_payloads/__init__.py +0 -0
  81. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/reference_payloads/bootstrap.py +0 -0
  82. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/reference_payloads/synthetic_trees.py +0 -0
  83. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/simulation_fixtures/circuits.response.txt +0 -0
  84. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/simulation_fixtures/panel.response.txt +0 -0
  85. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/simulation_fixtures/soe.response.txt +0 -0
  86. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/simulation_fixtures/status.response.txt +0 -0
  87. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_absent_readings_are_not_zero.py +0 -0
  88. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_accumulator.py +0 -0
  89. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_adapters_discovery.py +0 -0
  90. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_adopted_control.py +0 -0
  91. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_async_mqtt_client.py +0 -0
  92. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_auth_and_homie_helpers.py +0 -0
  93. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_auth_redaction.py +0 -0
  94. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_ca_pinning.py +0 -0
  95. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_capture_replay.py +0 -0
  96. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_captures_unchanged.py +0 -0
  97. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_catalog_divergence.py +0 -0
  98. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_circuit_fed_battery_captures.py +0 -0
  99. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_control_interceptor.py +0 -0
  100. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_declared_unvalued_captures.py +0 -0
  101. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_detection_auth.py +0 -0
  102. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_evse_lock_captures.py +0 -0
  103. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_evse_lock_control.py +0 -0
  104. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_exceptions.py +0 -0
  105. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_expected_failures_registry.py +0 -0
  106. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_factory_dispatch.py +0 -0
  107. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_feeds_role_captures.py +0 -0
  108. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_field_metadata.py +0 -0
  109. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_homie_schema_hash.py +0 -0
  110. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_https_transport.py +0 -0
  111. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_leaf_name_mismatch.py +0 -0
  112. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_live_flat_differential.py +0 -0
  113. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_mqtt_bridge.py +0 -0
  114. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_mqtt_connect_flow.py +0 -0
  115. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_mqtt_debounce.py +0 -0
  116. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_packaging.py +0 -0
  117. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_panel_properties_captures.py +0 -0
  118. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_phase_validation_configs.py +0 -0
  119. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_phase_validation_errors.py +0 -0
  120. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_plaintext_warning.py +0 -0
  121. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_positions_captures.py +0 -0
  122. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_protocol_conformance.py +0 -0
  123. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_protocol_models.py +0 -0
  124. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_redispatch_on_reconnect.py +0 -0
  125. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_register_passphrase_unavailable.py +0 -0
  126. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_register_rate_limit.py +0 -0
  127. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_register_rate_limit_companions.py +0 -0
  128. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_rest_transport_contract.py +0 -0
  129. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_fetch_transport_split.py +0 -0
  130. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_generation_cross_check.py +0 -0
  131. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_migration_delta.py +0 -0
  132. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_acceptance_companions.py +0 -0
  133. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_circuit_fed_battery.py +0 -0
  134. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_circuits.py +0 -0
  135. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_conformance.py +0 -0
  136. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_connection_health.py +0 -0
  137. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_declared_unvalued.py +0 -0
  138. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_device_display_name.py +0 -0
  139. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_discovery.py +0 -0
  140. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_evse_lock.py +0 -0
  141. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_extension.py +0 -0
  142. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_feeds_role.py +0 -0
  143. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_firmware.py +0 -0
  144. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_other_models.py +0 -0
  145. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_panel_properties.py +0 -0
  146. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_pcs.py +0 -0
  147. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_positions.py +0 -0
  148. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_service_entrance.py +0 -0
  149. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_shared_meters.py +0 -0
  150. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_shed_forecast.py +0 -0
  151. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_sheddable.py +0 -0
  152. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_space_less_meters.py +0 -0
  153. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_one_undeclared_devices.py +0 -0
  154. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_provenance.py +0 -0
  155. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_schema_zero_adapter.py +0 -0
  156. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_shared_http_client.py +0 -0
  157. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_shared_meters_captures.py +0 -0
  158. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_snapshot_readiness_gate.py +0 -0
  159. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_space_less_meters_captures.py +0 -0
  160. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_ssl_context.py +0 -0
  161. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_synthetic_trees.py +0 -0
  162. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/test_v2_status_parser.py +0 -0
  163. {span_panel_api-3.8.0b1 → span_panel_api-3.8.0b2}/tests/tls_fixtures.py +0 -0
@@ -16,6 +16,7 @@ xnotes/*
16
16
  .env
17
17
  .envrc
18
18
  .venv
19
+ .deps-installed
19
20
  .vscode/settings.json
20
21
 
21
22
  # mypy
@@ -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.0b1
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.0b1; extra == 'schema-1'
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.0b1"
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
- schema-1 = ["span-panel-api-schema-1>=1.4.0b1"]
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.8.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 AdoptedProperty, ControlTarget, FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot, V2HomieSchema
40
- from ..protocol import PanelCapability, SchemaAdapter
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
@@ -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
- document = _json_object(raw, path.name)
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"{path.name}: {device_id}")
136
- for device_id, body in _json_object(document["devices"], f"{path.name}: devices").items()
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"{path.name} holds {len(roots)} panels, not one")
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