span-panel-api 3.0.0b11__tar.gz → 3.0.0b13__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/CHANGELOG.md +25 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/PKG-INFO +1 -1
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/pyproject.toml +1 -1
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/__init__.py +4 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/models.py +130 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_public_api_unchanged.py +2 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_one_discovery.py +10 -0
- span_panel_api-3.0.0b13/tests/test_schema_one_extension.py +303 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/.gitignore +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/LICENSE +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/README.md +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/adapters.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/auth.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/const.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/dispatch.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/exceptions.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/mqtt/client.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/protocol.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/reference_payloads/README.md +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/reference_payloads/__init__.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/reference_payloads/homie_schema.json +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/schema_drift.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/conftest.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/fixtures/flat_wire.json +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_accumulator.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_adapters_discovery.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_adopted_control.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_adoption.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_catalog_divergence.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_detection_auth.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_exceptions.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_factory_dispatch.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_field_metadata.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_live_flat_differential.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_mqtt_homie.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_packaging.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_protocol_models.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_redispatch_on_reconnect.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_reference_tree_values.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_generation_cross_check.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_migration_delta.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_one_adapter.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_one_against_simulator.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_one_charge_limit.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_one_circuits.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_one_conformance.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_one_connection_health.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_one_devices.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_one_panel.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_one_pcs.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_one_service_entrance.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_one_shed_forecast.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_one_snapshot.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_one_transport.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_provenance.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_zero_adapter.py +0 -0
- {span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_shared_http_client.py +0 -0
|
@@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [3.0.0b13]
|
|
8
|
+
|
|
9
|
+
Carries `span-panel-api-schema-1` 0.1.0b10, which gives the two lugs devices their own extension subject. No behaviour change in this distribution: `ExtensionSubject.kind` documents `lugs` alongside the kinds it already accepted, which the type never
|
|
10
|
+
enumerated in code.
|
|
11
|
+
|
|
12
|
+
## [3.0.0b12]
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **A vendor property on a device this library already models now reaches a consumer, instead of stopping at diagnostics.** The schema is explicitly vendor-extensible and adoption covered only half of that: a device type nothing here models is adopted with
|
|
17
|
+
its readings, while a new property on the BESS, a charger, a circuit or the panel became a `DiscoveredMetadata` row — a declaration, no value, visible only to a maintainer reading a diagnostics attachment. `SpanPanelSnapshot.extension_properties` carries
|
|
18
|
+
those properties with their values, so a consumer can render what the publisher published.
|
|
19
|
+
- **`ExtensionProperty` and `ExtensionSubject`.** The subject names which modelled snapshot subject a property hangs off — `battery`, `mid`, `pv`, `panel`, and `evse`/`circuit` with the instance key the snapshot's own maps use — so a consumer resolves the
|
|
20
|
+
device it belongs on with a lookup it already performs. What is _not_ exposed is the field-level mapping: the subject is one value per device and cannot drift, while the wire-property-to-snapshot-field map is the adapter's internal business and exporting
|
|
21
|
+
it would freeze it as API.
|
|
22
|
+
|
|
23
|
+
### Notes
|
|
24
|
+
|
|
25
|
+
- **The value never reaches diagnostics, and that is structural rather than remembered.** `ExtensionProperty` is deliberately not a `FieldMetadata`, so it cannot enter the map `partition()` walks and has no path into a payload that leaves the machine. The
|
|
26
|
+
discovery rows keep flowing unchanged: the same property appears in both surfaces on purpose, joined by its `{node}/{property}` path — a declaration for the maintainer, a reading for the user.
|
|
27
|
+
- **Read-only by construction.** An extension property carries `settable` for curation triage and no set topic, and there is no member a write path could be built from. These properties live on exactly the devices whose curated controls do real work — the
|
|
28
|
+
EVSE limit refuses a value above the commissioned ceiling, the islanding assertion translates `GRID` into `ON_GRID` — and a generic write beside them would have neither.
|
|
29
|
+
- **Additive in both skew directions.** The snapshot field defaults empty, so an older adapter degrades to the previous behaviour with no new `SchemaAdapter` member required — a required member would fail at _discovery_, taking down every install whose
|
|
30
|
+
adapter wheel lags the bootstrap by a release. The reverse skew is handled by `span-panel-api-schema-1` 0.1.0b9 declaring this release as its floor.
|
|
31
|
+
|
|
7
32
|
## [3.0.0b11]
|
|
8
33
|
|
|
9
34
|
Carries `span-panel-api-schema-1` 0.1.0b8, which restores `dominant_power_source` on a panel with no MID. No change in this distribution.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.0b13
|
|
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
|
|
@@ -37,6 +37,8 @@ from .models import (
|
|
|
37
37
|
AdoptedDevice,
|
|
38
38
|
AdoptedProperty,
|
|
39
39
|
DiscoveredMetadata,
|
|
40
|
+
ExtensionProperty,
|
|
41
|
+
ExtensionSubject,
|
|
40
42
|
FieldMetadata,
|
|
41
43
|
HomieSchemaTypes,
|
|
42
44
|
SpanBatterySnapshot,
|
|
@@ -107,6 +109,8 @@ __all__ = [ # noqa: RUF022
|
|
|
107
109
|
"ADOPTION_TOPOLOGY_NODE",
|
|
108
110
|
"AdoptedDevice",
|
|
109
111
|
"AdoptedProperty",
|
|
112
|
+
"ExtensionProperty",
|
|
113
|
+
"ExtensionSubject",
|
|
110
114
|
# Snapshots
|
|
111
115
|
"SpanBatterySnapshot",
|
|
112
116
|
"SpanCircuitSnapshot",
|
|
@@ -776,6 +776,116 @@ class AdoptedDevice:
|
|
|
776
776
|
"""Everything outside `info` and `connection`, in declaration order."""
|
|
777
777
|
|
|
778
778
|
|
|
779
|
+
@dataclass(frozen=True, slots=True)
|
|
780
|
+
class ExtensionSubject:
|
|
781
|
+
"""Which modelled snapshot subject an extension property hangs off.
|
|
782
|
+
|
|
783
|
+
The adapter already knows which wire device populated which snapshot subject
|
|
784
|
+
-- that mapping is how `battery.power_w` gets a value. This type exposes the
|
|
785
|
+
*subject* and never the mapping: a consumer needs "this belongs to the
|
|
786
|
+
battery", not "this is how `battery.*` is assembled". Exporting the
|
|
787
|
+
field-level map would freeze the adapter's internals as API; exporting the
|
|
788
|
+
subject cannot, because it is one value per device drawn from a closed set.
|
|
789
|
+
|
|
790
|
+
Resolution is by declared `$type` and is indifferent to proxying: the
|
|
791
|
+
reference tree's own MID arrives proxied as `bess-mid` and still resolves to
|
|
792
|
+
`mid`. A proxied device of an *unmodelled* type resolves to no subject at all
|
|
793
|
+
and belongs to `AdoptedDevice`, which carries `parent` for that shape.
|
|
794
|
+
"""
|
|
795
|
+
|
|
796
|
+
kind: str
|
|
797
|
+
"""One of: `panel`, `lugs`, `battery`, `mid`, `pv`, `evse`, `circuit`.
|
|
798
|
+
|
|
799
|
+
`lugs` is separate from `panel` although its curated fields land in the panel
|
|
800
|
+
snapshot, because a subject is an identity and the two lugs devices are two
|
|
801
|
+
devices: they run the same firmware, so a vendor extension on one is the
|
|
802
|
+
expected case of the same extension on both, and folding them into `panel`
|
|
803
|
+
made two wire addresses one identity.
|
|
804
|
+
"""
|
|
805
|
+
|
|
806
|
+
instance_key: str | None = None
|
|
807
|
+
"""The snapshot map key for multi-instance kinds, `None` for the singletons.
|
|
808
|
+
|
|
809
|
+
The EVSE's `node_id` and the circuit's `circuit_id` -- the same keys
|
|
810
|
+
`snapshot.evse` and `snapshot.circuits` use, so a consumer holding the
|
|
811
|
+
snapshot resolves the subject with a lookup it already performs.
|
|
812
|
+
"""
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
@dataclass(frozen=True, slots=True)
|
|
816
|
+
class ExtensionProperty:
|
|
817
|
+
"""One property a *modelled* device declares that no snapshot field carries.
|
|
818
|
+
|
|
819
|
+
The value-carrying counterpart to `DiscoveredMetadata`, and deliberately a
|
|
820
|
+
third type rather than either neighbour. A discovered row exists to be
|
|
821
|
+
forwarded in diagnostics -- payloads that leave the machine into issues and
|
|
822
|
+
forum posts -- so it carries no value by construction. An `AdoptedProperty`
|
|
823
|
+
belongs to a device nothing here models, and its `set_topic` scoping *is* a
|
|
824
|
+
write authorisation this type must not inherit. This one belongs to a device
|
|
825
|
+
the adapter does model, exists to reach a consumer as a reading, and is
|
|
826
|
+
read-only by construction: no set topic, and no member a write path could be
|
|
827
|
+
built from.
|
|
828
|
+
|
|
829
|
+
**Not a `FieldMetadata`, and that is the diagnostics guarantee.**
|
|
830
|
+
`partition()` walks `build_field_metadata()`; this type rides
|
|
831
|
+
`build_snapshot()` instead, so there is no code path from here into
|
|
832
|
+
`SchemaFindings` or a diagnostics payload. The same wire property appears in
|
|
833
|
+
both surfaces on purpose -- as a declaration for the maintainer, as a value
|
|
834
|
+
for the user -- joined by the `{node}/{property}` path body.
|
|
835
|
+
|
|
836
|
+
**Read-only is not a policy this type states, it is a shape it has.** A
|
|
837
|
+
settable extension property is carried with `settable=True` for curation
|
|
838
|
+
triage and still surfaces as a reading: a control on a modelled device would
|
|
839
|
+
sit beside curated controls that do real safety work (the EVSE limit refuses
|
|
840
|
+
a value above the commissioned ceiling; schema_1 translates `GRID` into
|
|
841
|
+
`ON_GRID`), and a generic write path would bypass both on the same wire.
|
|
842
|
+
"""
|
|
843
|
+
|
|
844
|
+
subject: ExtensionSubject
|
|
845
|
+
"""The curated device this property hangs off."""
|
|
846
|
+
|
|
847
|
+
node_id: str
|
|
848
|
+
"""The Homie node, e.g. `battery-2`. Never `info` or `connection`."""
|
|
849
|
+
|
|
850
|
+
property_id: str
|
|
851
|
+
"""The Homie property, e.g. `cell-temperature`."""
|
|
852
|
+
|
|
853
|
+
datatype: str
|
|
854
|
+
"""The declared Homie datatype -- `float`, `integer`, `boolean`, `enum`, `string`."""
|
|
855
|
+
|
|
856
|
+
unit: str | None = None
|
|
857
|
+
"""The declared unit, verbatim. `None` is normal for a `boolean` or an `enum`."""
|
|
858
|
+
|
|
859
|
+
format: str | None = None
|
|
860
|
+
"""The declared `$format`: an option list for an `enum`, `min:max:step` for a number."""
|
|
861
|
+
|
|
862
|
+
settable: bool = False
|
|
863
|
+
"""Declaration fact, carried for curation triage.
|
|
864
|
+
|
|
865
|
+
Deliberately not paired with a set topic. See the read-only note above: the
|
|
866
|
+
absence of a write member is what makes the ruling structural rather than
|
|
867
|
+
remembered.
|
|
868
|
+
"""
|
|
869
|
+
|
|
870
|
+
value: str | None = None
|
|
871
|
+
"""The retained value as published, unparsed. `None` when declared and never valued."""
|
|
872
|
+
|
|
873
|
+
node_has_curated_siblings: bool = False
|
|
874
|
+
"""Whether the adapter maps any *other* property of this node to a snapshot field.
|
|
875
|
+
|
|
876
|
+
The one bit of the node-to-field mapping worth exporting: a vendor extending
|
|
877
|
+
`meter` is probably extending the meter. Stamped in one pass over knowledge
|
|
878
|
+
the adapter already holds, and it says nothing about *which* fields, so it
|
|
879
|
+
freezes no internals. A weak signal -- Homie nodes are organisational rather
|
|
880
|
+
than editorial -- and advisory only.
|
|
881
|
+
"""
|
|
882
|
+
|
|
883
|
+
@property
|
|
884
|
+
def path(self) -> str:
|
|
885
|
+
"""`{node}/{property}` -- how the capability catalogs spell it."""
|
|
886
|
+
return f"{self.node_id}/{self.property_id}"
|
|
887
|
+
|
|
888
|
+
|
|
779
889
|
@dataclass(frozen=True, slots=True)
|
|
780
890
|
class SpanPanelSnapshot:
|
|
781
891
|
"""Complete panel state — single point-in-time view."""
|
|
@@ -938,6 +1048,26 @@ class SpanPanelSnapshot:
|
|
|
938
1048
|
field that defaults empty is additive and costs neither.
|
|
939
1049
|
"""
|
|
940
1050
|
|
|
1051
|
+
extension_properties: tuple[ExtensionProperty, ...] = ()
|
|
1052
|
+
"""Properties *modelled* devices declare that no snapshot field carries.
|
|
1053
|
+
|
|
1054
|
+
The other half of vendor extensibility from `adopted_devices` above: that
|
|
1055
|
+
one covers a device type nothing models, this one a new property on a device
|
|
1056
|
+
something does. Until this existed the second case reached a consumer
|
|
1057
|
+
nowhere -- it became a `DiscoveredMetadata` row and stopped at diagnostics.
|
|
1058
|
+
|
|
1059
|
+
Empty is deliberately ambiguous between "none declared" and "this adapter
|
|
1060
|
+
predates the field", and a consumer must not try to tell them apart: the
|
|
1061
|
+
older-wheel case is the normal partial-upgrade state, because the adapters
|
|
1062
|
+
are separately published packages that version independently of this core.
|
|
1063
|
+
A defaulted snapshot field rather than a protocol member for exactly the
|
|
1064
|
+
reason `adopted_devices` gives -- a required member would fail at
|
|
1065
|
+
*discovery*, taking down every install whose adapter lags by one release.
|
|
1066
|
+
|
|
1067
|
+
schema_0 leaves it empty: flat has no device tree to find a declared-but
|
|
1068
|
+
-unmapped property in, and panels upgrade to v1.0 and stay there.
|
|
1069
|
+
"""
|
|
1070
|
+
|
|
941
1071
|
pcs: SpanPcsSnapshot | None = None
|
|
942
1072
|
"""The enclosure's Power Control System, when it publishes a `pcs` node. v1.0 only.
|
|
943
1073
|
|
|
@@ -114,6 +114,16 @@ def _snapshot_fields(snapshot: SpanPanelSnapshot) -> dict[str, str]:
|
|
|
114
114
|
fields: dict[str, str] = {}
|
|
115
115
|
for field in dataclasses.fields(snapshot):
|
|
116
116
|
value = getattr(snapshot, field.name)
|
|
117
|
+
if field.name == "extension_properties":
|
|
118
|
+
# Excluded, and the exclusion is the point rather than a convenience.
|
|
119
|
+
# This field carries every *unaddressed* declaration by construction,
|
|
120
|
+
# so republishing any property discovery reports would move it — and
|
|
121
|
+
# "does republishing this move a snapshot field" would answer yes for
|
|
122
|
+
# every discovered row, which is the question this oracle exists to
|
|
123
|
+
# ask. What the test still catches is the real defect: a discovered
|
|
124
|
+
# property that moves a *curated* field, i.e. one the mapper reads
|
|
125
|
+
# while the addressed set says it does not.
|
|
126
|
+
continue
|
|
117
127
|
if field.name in {"circuits", "evse"}:
|
|
118
128
|
for key, item in value.items():
|
|
119
129
|
_record(fields, f"{field.name}@{key}", item)
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""What the adapter emits as vendor extensions on devices it *does* model.
|
|
2
|
+
|
|
3
|
+
`build_extension_properties` is the value-carrying twin of `build_discovery`:
|
|
4
|
+
the same declared-but-unaddressed question, asked of the same tree, answered for
|
|
5
|
+
a consumer that will render it rather than for a maintainer reading an
|
|
6
|
+
attachment. The two must agree exactly, so the first test here is the join —
|
|
7
|
+
every extension row has a discovery row and vice versa.
|
|
8
|
+
|
|
9
|
+
The rest are structural, and they are structural on purpose. "Adopted values
|
|
10
|
+
never reach diagnostics" and "an extension property is read-only" are claims the
|
|
11
|
+
design makes about *shapes*, so they are asserted about shapes: a type that is
|
|
12
|
+
not a `FieldMetadata` cannot enter the metadata map that diagnostics is built
|
|
13
|
+
from, and a type with no set-topic member cannot grow a write path by someone
|
|
14
|
+
forgetting a rule.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
import json
|
|
21
|
+
|
|
22
|
+
from ebus_sdk.homie import DiscoveredDevice
|
|
23
|
+
import pytest
|
|
24
|
+
|
|
25
|
+
from span_panel_api.models import (
|
|
26
|
+
ADOPTION_IDENTITY_NODE,
|
|
27
|
+
ADOPTION_TOPOLOGY_NODE,
|
|
28
|
+
ExtensionProperty,
|
|
29
|
+
ExtensionSubject,
|
|
30
|
+
FieldMetadata,
|
|
31
|
+
SpanPanelSnapshot,
|
|
32
|
+
discovery_path,
|
|
33
|
+
)
|
|
34
|
+
from span_panel_api_schema_1.extension import build_extension_properties
|
|
35
|
+
from span_panel_api_schema_1.field_metadata import addressed_rows, build_discovery
|
|
36
|
+
from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree
|
|
37
|
+
from span_panel_api_schema_1.snapshot import build_snapshot
|
|
38
|
+
|
|
39
|
+
PANEL_DEVICE_ID = "example-40t-001"
|
|
40
|
+
"""The enclosure in the reference capture. Every other device is its child."""
|
|
41
|
+
|
|
42
|
+
Tree = dict[str, dict[str, str]]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _tree() -> Tree:
|
|
46
|
+
return {device_id: dict(topics) for device_id, topics in parent_child_tree().items()}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _snapshot(tree: Tree) -> SpanPanelSnapshot:
|
|
50
|
+
panel = device_from_topics(PANEL_DEVICE_ID, tree[PANEL_DEVICE_ID])
|
|
51
|
+
children = [device_from_topics(device_id, topics) for device_id, topics in tree.items() if device_id != PANEL_DEVICE_ID]
|
|
52
|
+
return build_snapshot(panel, children)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _devices(tree: Tree) -> list[DiscoveredDevice]:
|
|
56
|
+
return [device_from_topics(device_id, topics) for device_id, topics in tree.items()]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _short_type(device_type: str) -> str:
|
|
60
|
+
return device_type.rsplit(".", 1)[-1]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _declared_type(tree: Tree, device_id: str) -> str:
|
|
64
|
+
description: Mapping[str, object] = json.loads(tree[device_id]["$description"])
|
|
65
|
+
return str(description.get("type") or "")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --- the join with discovery ------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_every_extension_row_is_also_a_discovery_row() -> None:
|
|
72
|
+
"""The two surfaces describe the same properties, joined by the wire path.
|
|
73
|
+
|
|
74
|
+
A property in one and not the other is the defect this test exists for: an
|
|
75
|
+
entity a consumer renders while the diagnostics report it ignored, or a
|
|
76
|
+
property reported ignored while an entity shows its value. Both read as a
|
|
77
|
+
bug in whichever surface disagreed.
|
|
78
|
+
"""
|
|
79
|
+
tree = _tree()
|
|
80
|
+
snapshot = _snapshot(tree)
|
|
81
|
+
discovered = set(build_discovery(_devices(tree)))
|
|
82
|
+
|
|
83
|
+
for row in snapshot.extension_properties:
|
|
84
|
+
# The discovery path is keyed by the *device type*, so rebuild it from
|
|
85
|
+
# the subject's device rather than from the subject kind, which is a
|
|
86
|
+
# snapshot concept.
|
|
87
|
+
assert any(
|
|
88
|
+
path.endswith(f"/{row.path}") for path in discovered
|
|
89
|
+
), f"extension row {row.path} on {row.subject.kind} has no discovery row"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_no_extension_row_is_addressed() -> None:
|
|
93
|
+
"""An addressed property has a snapshot field; surfacing it twice is the bug."""
|
|
94
|
+
tree = _tree()
|
|
95
|
+
addressed = addressed_rows(_devices(tree))
|
|
96
|
+
for row in _snapshot(tree).extension_properties:
|
|
97
|
+
assert not any(
|
|
98
|
+
node == row.node_id and prop == row.property_id for _type, node, prop in addressed
|
|
99
|
+
), f"{row.path} is addressed and must not be emitted as an extension"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- structure: the diagnostics and read-only guarantees --------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_extension_property_is_not_field_metadata() -> None:
|
|
106
|
+
"""The diagnostics guarantee, asserted as a shape rather than as a rule.
|
|
107
|
+
|
|
108
|
+
`partition()` walks `build_field_metadata()`; a type that cannot enter that
|
|
109
|
+
map has no path into a payload that leaves the machine.
|
|
110
|
+
"""
|
|
111
|
+
row = ExtensionProperty(
|
|
112
|
+
subject=ExtensionSubject(kind="battery"),
|
|
113
|
+
node_id="battery-2",
|
|
114
|
+
property_id="cell-temperature",
|
|
115
|
+
datatype="float",
|
|
116
|
+
)
|
|
117
|
+
assert not isinstance(row, FieldMetadata)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_extension_property_has_no_write_surface() -> None:
|
|
121
|
+
"""No set topic, and no member one could be put in.
|
|
122
|
+
|
|
123
|
+
A literal `hasattr` check, because the point is to fail the change that adds
|
|
124
|
+
one rather than to describe today's fields.
|
|
125
|
+
"""
|
|
126
|
+
row = ExtensionProperty(
|
|
127
|
+
subject=ExtensionSubject(kind="evse", instance_key="acme-001"),
|
|
128
|
+
node_id="acme",
|
|
129
|
+
property_id="charge-limit",
|
|
130
|
+
datatype="float",
|
|
131
|
+
settable=True,
|
|
132
|
+
)
|
|
133
|
+
assert not hasattr(row, "set_topic")
|
|
134
|
+
assert row.settable is True, "settable is carried for triage, and still carries no write path"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_identity_and_topology_nodes_are_never_extensions() -> None:
|
|
138
|
+
"""`info` and `connection` resolve to the device card and the tree.
|
|
139
|
+
|
|
140
|
+
Excluded by node, as `adoption._readings` excludes them, because the
|
|
141
|
+
catalogs carry no marker for "this string is a device reference" and a name
|
|
142
|
+
list goes stale silently.
|
|
143
|
+
"""
|
|
144
|
+
for row in _snapshot(_tree()).extension_properties:
|
|
145
|
+
assert row.node_id not in (ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# --- emission against a synthetic vendor extension --------------------------
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
VENDOR_NODE = "battery-2"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _with_vendor_extension(tree: Tree, device_id: str) -> Tree:
|
|
155
|
+
"""Add an Acme pack node to one device's description, with one retained value."""
|
|
156
|
+
mutated = {other: dict(topics) for other, topics in tree.items()}
|
|
157
|
+
description = json.loads(mutated[device_id]["$description"])
|
|
158
|
+
description["nodes"][VENDOR_NODE] = {
|
|
159
|
+
"name": VENDOR_NODE,
|
|
160
|
+
"type": "energy.ebus.capability.vendor.acme.pack",
|
|
161
|
+
"properties": {
|
|
162
|
+
"cell-temperature": {"name": "Cell temperature", "datatype": "float", "unit": "°C"},
|
|
163
|
+
"pack-enabled": {"name": "Pack enabled", "datatype": "boolean", "settable": True},
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
mutated[device_id]["$description"] = json.dumps(description)
|
|
167
|
+
mutated[device_id][f"{VENDOR_NODE}/cell-temperature"] = "31.4"
|
|
168
|
+
return mutated
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _bess_device_id(tree: Tree) -> str:
|
|
172
|
+
for device_id in tree:
|
|
173
|
+
if _declared_type(tree, device_id).endswith(".bess"):
|
|
174
|
+
return device_id
|
|
175
|
+
pytest.skip("reference tree carries no BESS")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_a_vendor_node_on_a_modelled_device_becomes_extension_rows() -> None:
|
|
179
|
+
"""The whole point: a property hung off the BESS reaches the snapshot."""
|
|
180
|
+
tree = _tree()
|
|
181
|
+
device_id = _bess_device_id(tree)
|
|
182
|
+
snapshot = _snapshot(_with_vendor_extension(tree, device_id))
|
|
183
|
+
|
|
184
|
+
rows = {row.path: row for row in snapshot.extension_properties}
|
|
185
|
+
assert f"{VENDOR_NODE}/cell-temperature" in rows
|
|
186
|
+
assert f"{VENDOR_NODE}/pack-enabled" in rows
|
|
187
|
+
|
|
188
|
+
temperature = rows[f"{VENDOR_NODE}/cell-temperature"]
|
|
189
|
+
assert temperature.subject.kind == "battery"
|
|
190
|
+
assert temperature.subject.instance_key is None
|
|
191
|
+
assert temperature.datatype == "float"
|
|
192
|
+
assert temperature.unit == "°C"
|
|
193
|
+
assert temperature.value == "31.4"
|
|
194
|
+
# Declared and never valued is distinguishable from valued: `None` rather
|
|
195
|
+
# than an invented default, so a consumer can tell "nothing has arrived".
|
|
196
|
+
assert rows[f"{VENDOR_NODE}/pack-enabled"].value is None
|
|
197
|
+
assert rows[f"{VENDOR_NODE}/pack-enabled"].settable is True
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_a_wholly_vendor_node_has_no_curated_siblings() -> None:
|
|
201
|
+
"""The one exported bit of the node-to-field map, on a node with none."""
|
|
202
|
+
tree = _tree()
|
|
203
|
+
snapshot = _snapshot(_with_vendor_extension(tree, _bess_device_id(tree)))
|
|
204
|
+
rows = [row for row in snapshot.extension_properties if row.node_id == VENDOR_NODE]
|
|
205
|
+
assert rows
|
|
206
|
+
assert all(row.node_has_curated_siblings is False for row in rows)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_an_extension_to_a_curated_node_reports_curated_siblings() -> None:
|
|
210
|
+
"""A vendor extending `meter` is extending something this adapter reads."""
|
|
211
|
+
tree = _tree()
|
|
212
|
+
device_id = _bess_device_id(tree)
|
|
213
|
+
mutated = {other: dict(topics) for other, topics in tree.items()}
|
|
214
|
+
description = json.loads(mutated[device_id]["$description"])
|
|
215
|
+
meter = description["nodes"].get("meter")
|
|
216
|
+
if meter is None:
|
|
217
|
+
pytest.skip("reference BESS declares no meter node")
|
|
218
|
+
meter["properties"]["acme-cell-balance"] = {"name": "Cell balance", "datatype": "float", "unit": "%"}
|
|
219
|
+
mutated[device_id]["$description"] = json.dumps(description)
|
|
220
|
+
|
|
221
|
+
rows = [row for row in _snapshot(mutated).extension_properties if row.property_id == "acme-cell-balance"]
|
|
222
|
+
assert len(rows) == 1
|
|
223
|
+
assert rows[0].node_has_curated_siblings is True
|
|
224
|
+
# `%` is deliberately unrankable: the consumer maps no device class for it.
|
|
225
|
+
assert rows[0].unit == "%"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_an_undeclared_device_is_skipped_rather_than_emitted() -> None:
|
|
229
|
+
"""A device mid-discovery declares no type; that is a state, not a finding."""
|
|
230
|
+
tree = _tree()
|
|
231
|
+
device_id = _bess_device_id(tree)
|
|
232
|
+
mutated = {other: dict(topics) for other, topics in tree.items()}
|
|
233
|
+
description = json.loads(mutated[device_id]["$description"])
|
|
234
|
+
description.pop("type", None)
|
|
235
|
+
mutated[device_id]["$description"] = json.dumps(description)
|
|
236
|
+
|
|
237
|
+
subjects = [(device_from_topics(device_id, mutated[device_id]), ExtensionSubject(kind="battery"))]
|
|
238
|
+
assert build_extension_properties(subjects, addressed_rows(_devices(mutated))) == ()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_the_reference_tree_alone_emits_nothing_addressed() -> None:
|
|
242
|
+
"""A sanity floor: every row the untouched tree emits is genuinely unread."""
|
|
243
|
+
snapshot = _snapshot(_tree())
|
|
244
|
+
addressed = addressed_rows(_devices(_tree()))
|
|
245
|
+
for row in snapshot.extension_properties:
|
|
246
|
+
assert not any(node == row.node_id and prop == row.property_id for _t, node, prop in addressed)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_discovery_path_joins_the_two_surfaces() -> None:
|
|
250
|
+
"""The documented join key actually joins."""
|
|
251
|
+
tree = _tree()
|
|
252
|
+
device_id = _bess_device_id(tree)
|
|
253
|
+
mutated = _with_vendor_extension(tree, device_id)
|
|
254
|
+
discovered = set(build_discovery(_devices(mutated)))
|
|
255
|
+
expected = discovery_path(_short_type(_declared_type(mutated, device_id)), VENDOR_NODE, "cell-temperature")
|
|
256
|
+
assert expected in discovered
|
|
257
|
+
|
|
258
|
+
rows = {row.path for row in _snapshot(mutated).extension_properties}
|
|
259
|
+
assert f"{VENDOR_NODE}/cell-temperature" in rows
|
|
260
|
+
assert expected.endswith(f"/{VENDOR_NODE}/cell-temperature")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_the_two_lugs_devices_are_two_subjects() -> None:
|
|
264
|
+
"""Identical firmware on both lugs is what made one subject a collision.
|
|
265
|
+
|
|
266
|
+
A vendor extension on the upstream lugs is the expected case of the same
|
|
267
|
+
extension on the downstream lugs, so folding both into `panel` gave two wire
|
|
268
|
+
addresses one identity -- a consumer keying on
|
|
269
|
+
`(kind, instance_key, node/property)` would mint one id for two readings and
|
|
270
|
+
show whichever sorted first.
|
|
271
|
+
"""
|
|
272
|
+
tree = _tree()
|
|
273
|
+
lugs = [
|
|
274
|
+
device_id
|
|
275
|
+
for device_id in tree
|
|
276
|
+
if ".lugs" in _declared_type(tree, device_id) or _declared_type(tree, device_id).endswith("lugs")
|
|
277
|
+
]
|
|
278
|
+
if len(lugs) < 2:
|
|
279
|
+
pytest.skip("reference tree carries fewer than two lugs devices")
|
|
280
|
+
|
|
281
|
+
mutated = {other: dict(topics) for other, topics in tree.items()}
|
|
282
|
+
for device_id, value in zip(lugs, ("1.5", "99.9"), strict=False):
|
|
283
|
+
description = json.loads(mutated[device_id]["$description"])
|
|
284
|
+
description["nodes"]["acme"] = {
|
|
285
|
+
"name": "acme",
|
|
286
|
+
"type": "energy.ebus.capability.vendor.acme.balance",
|
|
287
|
+
"properties": {"phase-balance": {"name": "Phase balance", "datatype": "float", "unit": "%"}},
|
|
288
|
+
}
|
|
289
|
+
mutated[device_id]["$description"] = json.dumps(description)
|
|
290
|
+
mutated[device_id]["acme/phase-balance"] = value
|
|
291
|
+
|
|
292
|
+
rows = [row for row in _snapshot(mutated).extension_properties if row.path == "acme/phase-balance"]
|
|
293
|
+
assert len(rows) == 2
|
|
294
|
+
assert all(row.subject.kind == "lugs" for row in rows)
|
|
295
|
+
assert {row.subject.instance_key for row in rows} == {"upstream", "downstream"}
|
|
296
|
+
# Distinct identities carrying distinct readings, which is the point.
|
|
297
|
+
assert {row.value for row in rows} == {"1.5", "99.9"}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_every_subject_identity_is_unique_per_property() -> None:
|
|
301
|
+
"""No two rows may share `(kind, instance_key, path)` -- that tuple is the identity."""
|
|
302
|
+
identities = [(row.subject.kind, row.subject.instance_key, row.path) for row in _snapshot(_tree()).extension_properties]
|
|
303
|
+
assert len(identities) == len(set(identities))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/src/span_panel_api/reference_payloads/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/fixtures/panelbench_unvalued_by_both.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/simulation_fixtures/circuits.response.txt
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/simulation_fixtures/panel.response.txt
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/simulation_fixtures/soe.response.txt
RENAMED
|
File without changes
|
{span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/simulation_fixtures/status.response.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_generation_cross_check.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_one_against_simulator.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_one_connection_health.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{span_panel_api-3.0.0b11 → span_panel_api-3.0.0b13}/tests/test_schema_one_service_entrance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|