From 878870d0de137f4a45e9a8d75a3d8393a5944abd Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:10:47 -0700 Subject: [PATCH 1/2] feat(circuits): resolve shared meters and relays to circuit ids SpanCircuitSnapshot.meter_shared_with and relay_shared_with list the other circuits one meter measures or one relay switches, read from meter/ and switch/shared-with-device-ids. The circuit itself and ids naming no circuit in the snapshot are dropped, and the rest are ordered by device_id_order, under which circuit-9 sorts before circuit-10. Each is None while the list is undeclared or unpublished, and () where it names no known circuit. shared_meter_groups groups the circuits one meter measures, keyed by their first member, and is exported. Nothing is summed. Both lists are addressed properties now, so they are no longer discovery rows or extension readings. The acceptance tests these satisfy leave the expected-failure registry. Part of the spanos3/r202639/03 batch 2 work. --- .../src/span_panel_api_schema_1/circuits.py | 32 ++++- .../src/span_panel_api_schema_1/const.py | 1 + .../span_panel_api_schema_1/field_metadata.py | 3 + .../src/span_panel_api_schema_1/snapshot.py | 5 +- src/span_panel_api/__init__.py | 3 + src/span_panel_api/models.py | 59 +++++++++ tests/expected_failures.py | 12 -- tests/test_public_api_unchanged.py | 4 + tests/test_schema_one_discovery.py | 28 ++++- tests/test_schema_one_shared_meters.py | 115 ++++++++++++++++++ tests/test_shared_meters_captures.py | 60 +++++++++ 11 files changed, 305 insertions(+), 17 deletions(-) create mode 100644 tests/test_schema_one_shared_meters.py create mode 100644 tests/test_shared_meters_captures.py diff --git a/packages/schema-1/src/span_panel_api_schema_1/circuits.py b/packages/schema-1/src/span_panel_api_schema_1/circuits.py index 3b857e4..46c2d46 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/circuits.py +++ b/packages/schema-1/src/span_panel_api_schema_1/circuits.py @@ -33,7 +33,7 @@ from typing import TYPE_CHECKING -from span_panel_api.models import FEEDS_ROLES, FeedsRole, SpanCircuitSnapshot +from span_panel_api.models import FEEDS_ROLES, FeedsRole, SpanCircuitSnapshot, device_id_order from span_panel_api_schema_1.const import ( NODE_BREAKER, NODE_CONNECTION, @@ -56,6 +56,7 @@ PROP_RELAY, PROP_RELAY_CONTROLLABLE, PROP_RELAY_REQUESTER, + PROP_SHARED_WITH_DEVICE_IDS, PROP_SPACES, UNKNOWN, ) @@ -63,6 +64,8 @@ from span_panel_api_schema_1.panel import declares_node if TYPE_CHECKING: + from collections.abc import Collection + from ebus_sdk.homie import DiscoveredDevice @@ -269,10 +272,31 @@ def _feeds_role(device: DiscoveredDevice) -> FeedsRole | None: return next((role for role in FEEDS_ROLES if role == published), None) +def _shared_with(device: DiscoveredDevice, node: str, circuit_ids: Collection[str]) -> tuple[str, ...] | None: + """One node's `shared-with-device-ids`, resolved to the other circuits of the snapshot. + + A circuit's snapshot key is its device id, so resolving keeps the listed ids + that name another circuit, in `device_id_order`. `None` while the list is + undeclared or unpublished; an empty tuple where it names no known circuit. + """ + published = device.get_property(node, PROP_SHARED_WITH_DEVICE_IDS) + if published is None: + return None + listed = {part.strip() for part in str(published).split(",")} + return tuple(sorted((listed & set(circuit_ids)) - {device.device_id}, key=device_id_order)) + + def build_circuit( - device: DiscoveredDevice, device_type: str = "circuit", relative_position: str = "" + device: DiscoveredDevice, + device_type: str = "circuit", + relative_position: str = "", + circuit_ids: Collection[str] = frozenset(), ) -> SpanCircuitSnapshot: - """Build one circuit snapshot from its v1.0 device.""" + """Build one circuit snapshot from its v1.0 device. + + `circuit_ids` is every circuit device id in the tree, against which the + circuit's shared-with lists are resolved. + """ outside = not declares_spaces(device) raw_power = _number(device, NODE_METER, PROP_ACTIVE_POWER) # A hosted circuit is negated so positive means consumption; a meter outside @@ -313,6 +337,8 @@ def build_circuit( device_type=device_type, relative_position=relative_position, feeds_role=_feeds_role(device), + meter_shared_with=_shared_with(device, NODE_METER, circuit_ids), + relay_shared_with=_shared_with(device, NODE_SWITCH, circuit_ids), # Unknown without a pole count: a meter outside the panel has no # breaker, and a breaker whose count has not arrived has not said. is_240v=None if poles is None else poles >= 2, diff --git a/packages/schema-1/src/span_panel_api_schema_1/const.py b/packages/schema-1/src/span_panel_api_schema_1/const.py index f3e984f..b6631ba 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/const.py +++ b/packages/schema-1/src/span_panel_api_schema_1/const.py @@ -45,6 +45,7 @@ PROP_FEEDS_DEVICE_ID = "feeds-device-id" PROP_FEEDS_DEVICE_STATUS = "feeds-device-status" PROP_FEEDS_ROLE = "feeds-role" +PROP_SHARED_WITH_DEVICE_IDS = "shared-with-device-ids" PROP_FED_BY_DEVICE_ID = "fed-by-device-id" PROP_FED_BY_DEVICE_STATUS = "fed-by-device-status" NODE_DOOR = "door" diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index 7e2105d..f51ab58 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -433,6 +433,9 @@ def _lookup( # --- Circuit topology and PCS membership -------------------------------- (TYPE_CIRCUIT, NODE_CONNECTION, "feeds-device-id"), (TYPE_CIRCUIT, NODE_CONNECTION, "feeds-role"), + # Device references, resolved to the circuit ids of the snapshot. + (TYPE_CIRCUIT, NODE_METER, "shared-with-device-ids"), + (TYPE_CIRCUIT, NODE_SWITCH, "shared-with-device-ids"), (TYPE_CIRCUIT, NODE_PCS, "managed"), (TYPE_CIRCUIT, NODE_PCS, "priority"), # --- Lugs direction and the upstream device's link ----------------------- diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index 30cbcac..7604ddb 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -160,8 +160,11 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice]) -> # uses, and this loop is where a circuit's is decided -- deriving it a # second time downstream would be a second implementation free to drift. circuit_subjects: list[tuple[DiscoveredDevice, ExtensionSubject]] = [] + circuit_ids = frozenset(circuit.device_id for circuit in roles.circuits) for circuit in roles.circuits: - snapshot = build_circuit(circuit, device_type=der_type_by_circuit.get(circuit.device_id, "circuit")) + snapshot = build_circuit( + circuit, device_type=der_type_by_circuit.get(circuit.device_id, "circuit"), circuit_ids=circuit_ids + ) circuits[snapshot.circuit_id] = snapshot circuit_subjects.append((circuit, ExtensionSubject(kind="circuit", instance_key=snapshot.circuit_id))) diff --git a/src/span_panel_api/__init__.py b/src/span_panel_api/__init__.py index 6ffa3a0..63b7a7a 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -63,6 +63,7 @@ V2HomieSchema, V2StatusInfo, is_discovery_path, + shared_meter_groups, ) from .mqtt import ( ControlCommand, @@ -151,6 +152,8 @@ # `SpanCircuitSnapshot.feeds_role`. "FEEDS_ROLES", "FeedsRole", + # The circuits one meter measures, from `SpanCircuitSnapshot.meter_shared_with`. + "shared_meter_groups", # Factory "create_span_client", # Detection diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index c7f41f0..e284611 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -11,6 +11,7 @@ from collections.abc import Mapping from dataclasses import dataclass, field +import re from typing import Final, Literal # Homie schema type: {type_name: {property_name: {attribute: value}}} @@ -95,6 +96,22 @@ class SpanCircuitSnapshot: has no relay, no breaker and no shed priority, and occupies no position. """ + meter_shared_with: tuple[str, ...] | None = None + """The other circuits this circuit's meter also measures, by circuit id. + + From `meter/shared-with-device-ids`, without the circuit itself or an id + naming no circuit in the snapshot, ordered by `device_id_order`. `None` + where the circuit declares no such list or has not published it; `()` where + it lists no circuit the snapshot holds. The peers' readings are the same + meter's, so a consumer never adds them up; `shared_meter_groups` groups + them. + """ + relay_shared_with: tuple[str, ...] | None = None + """The other circuits this circuit's relay also switches, by circuit id. + + From `switch/shared-with-device-ids`, read as `meter_shared_with` is. + """ + feeds_role: FeedsRole | None = None """What the circuit feeds, from its `connection/feeds-role`. @@ -567,6 +584,48 @@ class FieldMetadata: _DISCOVERY_PREFIX = f"{DISCOVERY_NAMESPACE}." +def device_id_order(device_id: str) -> tuple[str | int, ...]: + """Sort key for device ids under which digit runs compare as numbers. + + `circuit-9` sorts before `circuit-10`. Splitting on a captured digit run + leaves the runs at the odd positions and the text between them at the even + ones, so every position holds the same type in every key. + """ + parts = re.split(r"(\d+)", device_id) + return tuple(int(part) if index % 2 else part for index, part in enumerate(parts)) + + +def shared_meter_groups(circuits: Mapping[str, SpanCircuitSnapshot]) -> dict[str, tuple[str, ...]]: + """The circuits one meter measures, grouped and keyed by their first member. + + A group holds every circuit linked through `meter_shared_with`, in + `device_id_order`, and its key is the first of them. A circuit that names no + known peer and is named by none is in no group. Only membership: no reading + is combined, because every member already reports the one meter's values. + """ + links: dict[str, set[str]] = {} + for circuit_id, circuit in circuits.items(): + for peer in circuit.meter_shared_with or (): + if peer in circuits and peer != circuit_id: + links.setdefault(circuit_id, set()).add(peer) + links.setdefault(peer, set()).add(circuit_id) + groups: dict[str, tuple[str, ...]] = {} + seen: set[str] = set() + for start in sorted(links, key=device_id_order): + if start in seen: + continue + members = {start} + pending = [start] + while pending: + for peer in links[pending.pop()] - members: + members.add(peer) + pending.append(peer) + seen |= members + ordered = tuple(sorted(members, key=device_id_order)) + groups[ordered[0]] = ordered + return groups + + def discovery_path(device_type: str, node_id: str, property_id: str) -> str: """The namespaced field path for one declared-but-unaddressed property. diff --git a/tests/expected_failures.py b/tests/expected_failures.py index 0e96b9e..27b283b 100644 --- a/tests/expected_failures.py +++ b/tests/expected_failures.py @@ -45,21 +45,9 @@ ), f"{_ACCEPTANCE}::test_every_model_property_is_none_where_not_published": _UNPUBLISHED_IS_NONE, f"{_ACCEPTANCE}::test_the_reference_main_32_publishes_none_of_them": _UNPUBLISHED_IS_NONE, - f"{_ACCEPTANCE}::test_shared_with_resolves_to_other_circuits_in_device_id_order": ( - f"shared-with resolved to circuit ids lands with {_SHARED}" - ), - f"{_ACCEPTANCE}::test_shared_with_orders_instance_numbers_numerically": ( - f"shared-with in numeric instance order lands with {_SHARED}" - ), - f"{_ACCEPTANCE}::test_a_shared_group_naming_no_known_circuit_is_still_shared": ( - f"an empty shared-with for a declared group lands with {_SHARED}" - ), f"{_COMPANIONS}::test_the_shipped_tree_reads_busbar_current_and_frequency": ( f"busbar current and frequency land with {_READINGS}" ), - f"{_COMPANIONS}::test_the_shipped_tree_resolves_its_shared_relay": ( - f"shared-with resolved to circuit ids lands with {_SHARED}" - ), } _DEVICE_TYPE_AS_PUBLISHED = "the library keeps device_type as published; a SOLAR feeds-role is carried by feeds_role" diff --git a/tests/test_public_api_unchanged.py b/tests/test_public_api_unchanged.py index c25b2e6..c7c8cf2 100644 --- a/tests/test_public_api_unchanged.py +++ b/tests/test_public_api_unchanged.py @@ -85,6 +85,10 @@ # the field defaults to `None`, and the flat schema declares no roles. "FEEDS_ROLES", "FeedsRole", + # Added 2026-10-09: groups the circuits that share one meter, from + # `SpanCircuitSnapshot.meter_shared_with`. Purely additive -- a snapshot in + # which no circuit shares a meter has no groups. + "shared_meter_groups", # Factory "create_span_client", # Detection diff --git a/tests/test_schema_one_discovery.py b/tests/test_schema_one_discovery.py index 2d6b754..dccc13d 100644 --- a/tests/test_schema_one_discovery.py +++ b/tests/test_schema_one_discovery.py @@ -267,7 +267,9 @@ def test_an_off_snapshot_route_that_became_observable_must_be_retired() -> None: _FEEDS_ROLE: Declaration = (TYPE_CIRCUIT, "connection", "feeds-role") -_ABSENT_FROM_THE_CAPTURE: frozenset[Declaration] = frozenset({_FEEDS_ROLE}) +_METER_SHARED_WITH: Declaration = (TYPE_CIRCUIT, "meter", "shared-with-device-ids") +_SWITCH_SHARED_WITH: Declaration = (TYPE_CIRCUIT, "switch", "shared-with-device-ids") +_ABSENT_FROM_THE_CAPTURE: frozenset[Declaration] = frozenset({_FEEDS_ROLE, _METER_SHARED_WITH, _SWITCH_SHARED_WITH}) """Addressed entries the capture never declares, each proved on a synthetic declaration below.""" _A_LOAD_CIRCUIT = "0ab966b95f92a6a51ec548485aa85f54" @@ -310,6 +312,30 @@ def test_feeds_role_moves_the_snapshot_on_a_circuit_that_declares_it() -> None: assert _FEEDS_ROLE in _CONSUMED_WITHOUT_A_ROW +_ANOTHER_LOAD_CIRCUIT = "d3724e0d660ba506aa79c1cafe5d1181" + + +@pytest.mark.parametrize( + ("declaration", "field"), + [(_METER_SHARED_WITH, "meter_shared_with"), (_SWITCH_SHARED_WITH, "relay_shared_with")], +) +def test_shared_with_moves_the_snapshot_on_a_circuit_that_declares_it(declaration: Declaration, field: str) -> None: + """The experiment above, run on a circuit given the declaration the capture lacks.""" + _type, node, prop = declaration + tree = _tree() + description = json.loads(tree[_A_LOAD_CIRCUIT]["$description"]) + description["nodes"][node]["properties"][prop] = {"datatype": "string"} + tree[_A_LOAD_CIRCUIT]["$description"] = json.dumps(description) + tree[_A_LOAD_CIRCUIT][f"{node}/{prop}"] = "probe-value" + baseline = _snapshot_fields(_snapshot(tree)) + tree[_A_LOAD_CIRCUIT][f"{node}/{prop}"] = _ANOTHER_LOAD_CIRCUIT + after = _snapshot_fields(_snapshot(tree)) + + moved = {path for path, value in after.items() if baseline.get(path) != value} + assert moved == {f"circuits@{_A_LOAD_CIRCUIT}.{field}"} + assert declaration in _CONSUMED_WITHOUT_A_ROW + + def test_no_addressed_entry_duplicates_a_metadata_row() -> None: """The four tables partition the addressed set; they do not overlap. diff --git a/tests/test_schema_one_shared_meters.py b/tests/test_schema_one_shared_meters.py new file mode 100644 index 0000000..2a6bb96 --- /dev/null +++ b/tests/test_schema_one_shared_meters.py @@ -0,0 +1,115 @@ +"""Circuits that share a meter or a relay name each other. + +`meter/` and `switch/shared-with-device-ids` list the other devices one meter +measures or one relay switches. They are resolved to the circuit ids of the +snapshot: the circuit itself and ids naming no circuit are dropped, and the +rest are ordered by device id with instance numbers compared as numbers. +Undeclared or not yet published reads `None`; declared with no known peer reads +`()`. `shared_meter_groups` groups the circuits one meter measures; nothing is +summed. +""" + +from __future__ import annotations + +from conftest import flat_schema +from reference_payloads.schema_one import parent_child_tree, replay +from reference_payloads.synthetic_trees import ( + PANEL_ID, + SyntheticDevice, + hosted_circuit, + panel, + shared_pair, + tree, + without_values, +) +from span_panel_api import SpanPanelSnapshot, shared_meter_groups +from span_panel_api.models import device_id_order +from span_panel_api_schema_0 import SchemaZeroAdapter + +_REFERENCE_PANEL = "example-40t-001" + + +def _snapshot(*circuits: SyntheticDevice) -> SpanPanelSnapshot: + return replay(tree(panel(), *circuits), PANEL_ID).build_snapshot() + + +def test_a_shared_pair_names_each_other_for_meter_and_relay() -> None: + circuits = _snapshot(*shared_pair("c-1", "c-2")).circuits + + assert (circuits["c-1"].meter_shared_with, circuits["c-1"].relay_shared_with) == (("c-2",), ("c-2",)) + assert (circuits["c-2"].meter_shared_with, circuits["c-2"].relay_shared_with) == (("c-1",), ("c-1",)) + + +def test_the_circuit_itself_and_unknown_ids_are_dropped() -> None: + circuits = _snapshot( + hosted_circuit("c-1", (1,), meter_shared_with=("c-1", "ghost", "c-2"), relay_shared_with=("ghost",)), + hosted_circuit("c-2", (3,)), + ).circuits + + assert circuits["c-1"].meter_shared_with == ("c-2",) + assert circuits["c-1"].relay_shared_with == () + + +def test_instance_numbers_are_ordered_as_numbers() -> None: + circuits = _snapshot( + hosted_circuit("c-1", (1,), meter_shared_with=("c-10", "c-9")), + hosted_circuit("c-9", (3,)), + hosted_circuit("c-10", (5,)), + ).circuits + + assert circuits["c-1"].meter_shared_with == ("c-9", "c-10") + assert sorted(["c-10", "c-9", "c-1", "meter-a"], key=device_id_order) == ["c-1", "c-9", "c-10", "meter-a"] + + +def test_undeclared_shared_with_reads_none() -> None: + circuit = _snapshot(hosted_circuit("c-1", (1,))).circuits["c-1"] + + assert (circuit.meter_shared_with, circuit.relay_shared_with) == (None, None) + + +def test_declared_shared_with_not_yet_published_reads_none() -> None: + first, second = shared_pair("c-1", "c-2") + unpublished = without_values(first, "meter/shared-with-device-ids", "switch/shared-with-device-ids") + snapshot = _snapshot(unpublished, second) + + assert (snapshot.circuits["c-1"].meter_shared_with, snapshot.circuits["c-1"].relay_shared_with) == (None, None) + + +def test_declared_with_no_known_peer_reads_empty_and_has_no_group() -> None: + snapshot = _snapshot(hosted_circuit("c-1", (1,), meter_shared_with=("ghost",))) + + assert snapshot.circuits["c-1"].meter_shared_with == () + assert shared_meter_groups(snapshot.circuits) == {} + + +def test_groups_are_keyed_by_their_first_member() -> None: + snapshot = _snapshot(*shared_pair("c-10", "c-9", space=1), *shared_pair("c-2", "c-3", space=3)) + + assert shared_meter_groups(snapshot.circuits) == {"c-2": ("c-2", "c-3"), "c-9": ("c-9", "c-10")} + + +def test_a_group_holds_every_circuit_one_meter_measures() -> None: + """Named by any member, a circuit is in the group, even where its own list has not arrived.""" + snapshot = _snapshot( + hosted_circuit("c-1", (1,), meter_shared_with=("c-2",)), + hosted_circuit("c-2", (1,), meter_shared_with=("c-3",)), + hosted_circuit("c-3", (1,)), + ) + + assert shared_meter_groups(snapshot.circuits) == {"c-1": ("c-1", "c-2", "c-3")} + + +def test_the_reference_tree_shares_nothing() -> None: + snapshot = replay(parent_child_tree(), _REFERENCE_PANEL).build_snapshot() + + assert all( + (circuit.meter_shared_with, circuit.relay_shared_with) == (None, None) for circuit in snapshot.circuits.values() + ) + assert shared_meter_groups(snapshot.circuits) == {} + + +def test_a_flat_panel_shares_nothing() -> None: + snapshot = SchemaZeroAdapter(serial_number="sim-40t-001", schema=flat_schema(40)).build_snapshot() + + assert all(circuit.meter_shared_with is None for circuit in snapshot.circuits.values()) + assert shared_meter_groups(snapshot.circuits) == {} diff --git a/tests/test_shared_meters_captures.py b/tests/test_shared_meters_captures.py new file mode 100644 index 0000000..553f1b3 --- /dev/null +++ b/tests/test_shared_meters_captures.py @@ -0,0 +1,60 @@ +"""Shared meters and relays, over every reference capture. + +Each circuit's peers and each group are compared with what the capture's own +`shared-with-device-ids` values list. +""" + +from __future__ import annotations + +import pytest + +from reference_payloads.captures import CAPTURES, CaptureTree, capture_adapter, capture_snapshot, capture_tree +from span_panel_api import shared_meter_groups +from span_panel_api.models import device_id_order, is_discovery_path + +_SHARED_WITH = ("meter/shared-with-device-ids", "switch/shared-with-device-ids") + + +def _declared_peers(tree: CaptureTree, circuit_id: str, topic: str) -> tuple[str, ...] | None: + published = tree.value(circuit_id, topic) + if published is None: + return None + circuits = set(tree.circuits()) + listed = {peer.strip() for peer in published.split(",") if peer.strip()} + return tuple(sorted((listed & circuits) - {circuit_id}, key=device_id_order)) + + +@pytest.mark.parametrize("stem", CAPTURES) +def test_each_circuit_names_the_peers_its_capture_lists(stem: str) -> None: + tree, circuits = capture_tree(stem), capture_snapshot(stem).circuits + for circuit_id in tree.circuits(): + circuit = circuits[circuit_id] + assert circuit.meter_shared_with == _declared_peers(tree, circuit_id, _SHARED_WITH[0]), circuit_id + assert circuit.relay_shared_with == _declared_peers(tree, circuit_id, _SHARED_WITH[1]), circuit_id + + +@pytest.mark.parametrize("stem", CAPTURES) +def test_shared_meters_group_exactly_their_declared_peers(stem: str) -> None: + tree = capture_tree(stem) + members: set[frozenset[str]] = set() + for circuit_id in tree.circuits(): + peers = _declared_peers(tree, circuit_id, _SHARED_WITH[0]) + if peers: + members.add(frozenset({circuit_id, *peers})) + expected = {group[0]: group for group in (tuple(sorted(member, key=device_id_order)) for member in members)} + + assert shared_meter_groups(capture_snapshot(stem).circuits) == expected + + +def test_the_captures_hold_shared_meters() -> None: + assert any(shared_meter_groups(capture_snapshot(stem).circuits) for stem in CAPTURES) + + +@pytest.mark.parametrize("stem", CAPTURES) +def test_shared_with_is_neither_discovered_nor_an_extension_reading(stem: str) -> None: + adapter = capture_adapter(stem) + discovered = [path for path in adapter.build_field_metadata() if is_discovery_path(path)] + extension = {f"{row.node_id}/{row.property_id}" for row in adapter.build_snapshot().extension_properties} + + assert not [path for path in discovered if path.endswith(tuple(f"/{topic}" for topic in _SHARED_WITH))] + assert not extension & set(_SHARED_WITH) From cf99640accbe654306bf88bae4854738620e698a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:43:30 -0700 Subject: [PATCH 2/2] build(schema-1): record the core floor this parser needs at release The parser now fills snapshot fields new in span-panel-api 3.8.0b1 and imports device_id_order from its models, so the floor rises to 3.8.0b1 when that release is cut. Part of the spanos3/r202639/03 batch 2 work. --- packages/schema-1/pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index 7222937..7009099 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -28,6 +28,8 @@ dependencies = [ # Raised to 3.6.1, whose SpanPanelSnapshot.pv documents what this parser builds. # Raised to 3.6.2, which waits on this parser's missing labels after a bridge # rebuild as well as on connect; the fix spans both, so the pair moves together. + # To rise to 3.8.0b1 when that release is cut: this parser fills snapshot + # fields new in it and imports `device_id_order` from its models. "span-panel-api>=3.6.2,<4.0", # Only this distribution depends on the eBus SDK. The bootstrap and # schema-0 stay clean, so a flat-panel install never pulls it in — which is