span-panel-api-schema-0 1.0.0b1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ __pycache__/
2
+ build/
3
+ dist/
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ .cursor/
7
+ .cursorignore
8
+ .cursor
9
+ .cursorindexingignore
10
+ xnotes/*
11
+
12
+ # pyenv
13
+ .python-version
14
+
15
+ # Environments
16
+ .env
17
+ .envrc
18
+ .venv
19
+ .vscode/settings.json
20
+
21
+ # mypy
22
+ .mypy_cache/
23
+ .dmypy.json
24
+ dmypy.json
25
+
26
+ # ruff
27
+ .ruff_cache/
28
+
29
+ # JetBrains
30
+ .idea/
31
+
32
+ /coverage.xml
33
+ /.coverage
34
+ coverage_output.log
35
+ **/.DS_Store
36
+ .local_coverage_data
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to `span-panel-api-schema-0` are documented in this file.
4
+
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
+
7
+ Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is fixed — the flat single-device schema, SPAN firmware `r202603` through `r202627` — and is identified by `SUPPORTS_DATA_MODEL_VERSIONS`
8
+ rather than by this version number. A release here means this parser changed, never that the panel did.
9
+
10
+ ## [1.0.0b1] - 08/2026
11
+
12
+ Pre-release. First release as a standalone distribution.
13
+
14
+ ### Added
15
+
16
+ - **The flat-schema parser, extracted from `span-panel-api` 2.6.4.** Relocated verbatim from `span_panel_api._impl.schema_0` to `span_panel_api_schema_0`; only import statements changed. Registers itself as `schema_0` under the
17
+ `span_panel_api.schema_adapters` entry-point group, which is the only way `span-panel-api` reaches it — the bootstrap never imports this package.
18
+ - **`SCHEMA_ANCHOR`** (`sha256:d347556a07d98f40`, firmware `spanos2/r202603/05`) — the schema revision every hardcoded fact in this package was read from, with `SCHEMA_ANCHOR_FIELD` naming the field it comes from (`typesSchemaHash`). The field is
19
+ per-adapter: parent/child firmware renames it to `deviceClassesSchemaHash` along with the block it covers, so a future `schema_1` declares its own rather than inheriting one that does not exist on its firmware.
20
+ - **Provenance tests** asserting that all 64 hardcoded `(node_type, property_id)` pairs still resolve against the captured schema, that `HOMIE_DOMAIN` / `HOMIE_VERSION` still match it, and that the two lugs subtypes real firmware publishes remain absent
21
+ from the schema _and_ present in the metadata alias table. This is the only signal that catches schema drift before release; every other symptom reaches production as a silent absence.
22
+ - **A `py.typed` marker**, so consumers type-check against this package's real annotations rather than resolving everything it exports as `Any`.
23
+
24
+ ### Known deviations from the published schema
25
+
26
+ - **Circuit `active-power` is treated as watts, though the schema declares kilowatts.** Real panels publish watts; this was established against live hardware and the 1000× correction was removed accordingly. A test asserts the schema still says `kW`, so
27
+ the day SPAN corrects it we find out rather than discovering it as a factor-of-1000 error.
28
+ - **`energy.ebus.device.lugs.upstream` / `.downstream` are parsed but undeclared.** Firmware publishes these node types in `$description`; the schema declares only the base `energy.ebus.device.lugs`. Property metadata for them resolves through an alias to
29
+ the base type.
30
+
31
+ ### Retirement
32
+
33
+ SPAN retires the flat schema in the same firmware release that introduces the parent/child model (`r202633`; fleet rollout projected, not committed, for the first two weeks of September 2026). This package stops being published once the fleet has moved.
34
+ Published versions remain on PyPI for anyone still running older firmware.
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.4
2
+ Name: span-panel-api-schema-0
3
+ Version: 1.0.0b1
4
+ Summary: Flat-schema (data-model-version absent) parser for span-panel-api
5
+ Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
6
+ Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
7
+ Author: SpanPanel
8
+ License-Expression: MIT
9
+ Requires-Python: <4.0,>=3.10
10
+ Requires-Dist: span-panel-api<4.0,>=3.0.0b1
11
+ Description-Content-Type: text/markdown
12
+
13
+ # span-panel-api-schema-0
14
+
15
+ The **flat-schema** parser for [`span-panel-api`](https://github.com/SpanPanel/span-panel-api): the single-device Homie model published by SPAN firmware `r202603` through `r202627`, which carries no `data-model-version`.
16
+
17
+ ## Why this is a separate distribution
18
+
19
+ `span-panel-api` is a transport and a dispatcher. It knows how to connect to a panel's MQTT broker, route messages, and choose a parser — but it contains no parsing code and no Homie type strings. Each wire format ships as its own distribution and
20
+ registers itself under the `span_panel_api.schema_adapters` entry-point group.
21
+
22
+ That split exists because the two halves break on different axes. The wire format changes when SPAN ships firmware; the library API changes when we do. Separate distributions let each carry its own version, so a consumer can pin them independently and add
23
+ support for a new panel schema by installing a package rather than by upgrading the transport.
24
+
25
+ ## Installation
26
+
27
+ ```console
28
+ pip install span-panel-api span-panel-api-schema-0
29
+ ```
30
+
31
+ Installing this package is what makes flat-schema panels work. `span-panel-api` on its own will connect and then raise `SpanPanelAdapterMissingError` naming the adapter it could not find.
32
+
33
+ A consumer that wants to support panels on either schema installs both adapters:
34
+
35
+ ```console
36
+ pip install span-panel-api span-panel-api-schema-0 span-panel-api-schema-1
37
+ ```
38
+
39
+ Dispatch happens at runtime, per panel, from the `data-model-version` the panel reports.
40
+
41
+ ## Retirement
42
+
43
+ SPAN retires the flat schema in the same release that introduces the parent/child model (`r202633`, fleet rollout projected for early September 2026). When the fleet has moved, consumers drop this package from their requirements. Published versions stay on
44
+ PyPI for anyone still running older firmware.
@@ -0,0 +1,32 @@
1
+ # span-panel-api-schema-0
2
+
3
+ The **flat-schema** parser for [`span-panel-api`](https://github.com/SpanPanel/span-panel-api): the single-device Homie model published by SPAN firmware `r202603` through `r202627`, which carries no `data-model-version`.
4
+
5
+ ## Why this is a separate distribution
6
+
7
+ `span-panel-api` is a transport and a dispatcher. It knows how to connect to a panel's MQTT broker, route messages, and choose a parser — but it contains no parsing code and no Homie type strings. Each wire format ships as its own distribution and
8
+ registers itself under the `span_panel_api.schema_adapters` entry-point group.
9
+
10
+ That split exists because the two halves break on different axes. The wire format changes when SPAN ships firmware; the library API changes when we do. Separate distributions let each carry its own version, so a consumer can pin them independently and add
11
+ support for a new panel schema by installing a package rather than by upgrading the transport.
12
+
13
+ ## Installation
14
+
15
+ ```console
16
+ pip install span-panel-api span-panel-api-schema-0
17
+ ```
18
+
19
+ Installing this package is what makes flat-schema panels work. `span-panel-api` on its own will connect and then raise `SpanPanelAdapterMissingError` naming the adapter it could not find.
20
+
21
+ A consumer that wants to support panels on either schema installs both adapters:
22
+
23
+ ```console
24
+ pip install span-panel-api span-panel-api-schema-0 span-panel-api-schema-1
25
+ ```
26
+
27
+ Dispatch happens at runtime, per panel, from the `data-model-version` the panel reports.
28
+
29
+ ## Retirement
30
+
31
+ SPAN retires the flat schema in the same release that introduces the parent/child model (`r202633`, fleet rollout projected for early September 2026). When the fleet has moved, consumers drop this package from their requirements. Published versions stay on
32
+ PyPI for anyone still running older firmware.
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "span-panel-api-schema-0"
3
+ version = "1.0.0b1"
4
+ description = "Flat-schema (data-model-version absent) parser for span-panel-api"
5
+ authors = [
6
+ {name = "SpanPanel"}
7
+ ]
8
+ readme = "README.md"
9
+ license = "MIT"
10
+ requires-python = ">=3.10,<4.0"
11
+ dependencies = [
12
+ "span-panel-api>=3.0.0b1,<4.0",
13
+ ]
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/SpanPanel/span-panel-api"
17
+ Issues = "https://github.com/SpanPanel/span-panel-api/issues"
18
+
19
+ # The whole point of this distribution. The bootstrap finds this adapter by
20
+ # discovering the group, never by importing this package.
21
+ [project.entry-points."span_panel_api.schema_adapters"]
22
+ schema_0 = "span_panel_api_schema_0:SchemaZeroAdapter"
23
+
24
+ # Resolve the bootstrap from the workspace when developing here. Published
25
+ # wheels are unaffected: this table is uv-only metadata and the dependency
26
+ # above is what a consumer installing from PyPI sees.
27
+ [tool.uv.sources]
28
+ span-panel-api = { workspace = true }
29
+
30
+ [build-system]
31
+ requires = ["hatchling"]
32
+ build-backend = "hatchling.build"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/span_panel_api_schema_0"]
@@ -0,0 +1,11 @@
1
+ """Flat-schema adapter package (data-model-version absent)."""
2
+
3
+ from span_panel_api_schema_0.adapter import SchemaZeroAdapter
4
+
5
+ # Re-exported from the adapter rather than restated. The protocol requires the
6
+ # range as a class attribute, so the class is the source of truth; a second
7
+ # literal here would be free to drift, and nothing would notice until a panel
8
+ # reported a version this adapter claims — falsely — to support.
9
+ SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = SchemaZeroAdapter.SUPPORTS_DATA_MODEL_VERSIONS
10
+
11
+ __all__ = ["SUPPORTS_DATA_MODEL_VERSIONS", "SchemaZeroAdapter"]
@@ -0,0 +1,274 @@
1
+ """Homie v5 property accumulator with lifecycle state machine.
2
+
3
+ Generic Homie protocol layer that knows nothing about SPAN-specific
4
+ concepts. Stores property values, tracks lifecycle, and exposes a
5
+ query API for higher-level consumers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ import enum
12
+ import json
13
+ import logging
14
+ import time
15
+
16
+ from span_panel_api.mqtt.const import HOMIE_STATE_DISCONNECTED, HOMIE_STATE_LOST, HOMIE_STATE_READY
17
+ from span_panel_api_schema_0.const import TOPIC_PREFIX
18
+
19
+ _LOGGER = logging.getLogger(__name__)
20
+
21
+ # Callback signature: (node_id, prop_id, value, old_value)
22
+ PropertyCallback = Callable[[str, str, str, str | None], None]
23
+
24
+
25
+ class HomieLifecycle(enum.Enum):
26
+ """Homie device lifecycle states."""
27
+
28
+ DISCONNECTED = "disconnected"
29
+ CONNECTED = "connected"
30
+ DESCRIPTION_RECEIVED = "description_received"
31
+ READY = "ready"
32
+
33
+
34
+ class HomiePropertyAccumulator:
35
+ """Accumulate Homie v5 property values and track device lifecycle.
36
+
37
+ Topic prefix: ``ebus/5/{serial_number}``
38
+
39
+ All methods must be called from the asyncio event loop thread.
40
+ """
41
+
42
+ def __init__(self, serial_number: str) -> None:
43
+ self._serial_number = serial_number
44
+ self._topic_prefix = f"{TOPIC_PREFIX}/{serial_number}"
45
+
46
+ # Lifecycle
47
+ self._lifecycle = HomieLifecycle.DISCONNECTED
48
+ self._received_state_ready = False
49
+ self._received_description = False
50
+ self._ready_since: float = 0.0
51
+
52
+ # Property storage
53
+ self._property_values: dict[str, dict[str, str]] = {}
54
+ self._property_timestamps: dict[str, dict[str, int]] = {}
55
+ self._target_values: dict[str, dict[str, str]] = {}
56
+
57
+ # Node type mapping from $description
58
+ self._node_types: dict[str, str] = {}
59
+
60
+ # Dirty tracking
61
+ self._dirty_nodes: set[str] = set()
62
+
63
+ # Callbacks
64
+ self._property_callbacks: list[PropertyCallback] = []
65
+
66
+ # -- Public properties ---------------------------------------------------
67
+
68
+ @property
69
+ def serial_number(self) -> str:
70
+ """Serial number this accumulator tracks."""
71
+ return self._serial_number
72
+
73
+ @property
74
+ def lifecycle(self) -> HomieLifecycle:
75
+ """Current lifecycle state."""
76
+ return self._lifecycle
77
+
78
+ @property
79
+ def ready_since(self) -> float:
80
+ """Monotonic timestamp of the last READY transition, 0.0 if never ready."""
81
+ return self._ready_since
82
+
83
+ def is_ready(self) -> bool:
84
+ """True when lifecycle is READY."""
85
+ return self._lifecycle == HomieLifecycle.READY
86
+
87
+ # -- Message routing -----------------------------------------------------
88
+
89
+ def handle_message(self, topic: str, payload: str) -> None:
90
+ """Route an MQTT message to the appropriate handler."""
91
+ prefix_with_sep = f"{self._topic_prefix}/"
92
+ if not topic.startswith(prefix_with_sep):
93
+ return
94
+
95
+ suffix = topic[len(prefix_with_sep) :]
96
+
97
+ if suffix == "$state":
98
+ self._handle_state(payload)
99
+ elif suffix == "$description":
100
+ self._handle_description(payload)
101
+ elif suffix.endswith("/set"):
102
+ return # ignore /set topics
103
+ elif "/" in suffix:
104
+ parts = suffix.split("/", 1)
105
+ node_id = parts[0]
106
+ prop_part = parts[1]
107
+ if prop_part.endswith("/$target"):
108
+ # Target value: {node_id}/{prop_id}/$target
109
+ prop_id = prop_part[: -len("/$target")]
110
+ self._handle_target(node_id, prop_id, payload)
111
+ else:
112
+ # Reported value: {node_id}/{prop_id}
113
+ self._handle_property(node_id, prop_part, payload)
114
+
115
+ # -- Query API -----------------------------------------------------------
116
+
117
+ def get_prop(self, node_id: str, prop_id: str, default: str = "") -> str:
118
+ """Get a property's reported value."""
119
+ return self._property_values.get(node_id, {}).get(prop_id, default)
120
+
121
+ def get_timestamp(self, node_id: str, prop_id: str) -> int:
122
+ """Get the epoch timestamp of a property's last update."""
123
+ return self._property_timestamps.get(node_id, {}).get(prop_id, 0)
124
+
125
+ def get_target(self, node_id: str, prop_id: str) -> str | None:
126
+ """Get a property's target value, or None if no target set."""
127
+ return self._target_values.get(node_id, {}).get(prop_id)
128
+
129
+ def has_target(self, node_id: str, prop_id: str) -> bool:
130
+ """True if a target value exists for the given property."""
131
+ return prop_id in self._target_values.get(node_id, {})
132
+
133
+ def find_node_by_type(self, type_str: str) -> str | None:
134
+ """Find the first node ID matching a given type string."""
135
+ for node_id, node_type in self._node_types.items():
136
+ if node_type == type_str:
137
+ return node_id
138
+ return None
139
+
140
+ def nodes_by_type(self, type_str: str) -> list[str]:
141
+ """Return all node IDs matching a given type string."""
142
+ return [nid for nid, ntype in self._node_types.items() if ntype == type_str]
143
+
144
+ def get_node_type(self, node_id: str) -> str:
145
+ """Get the type string for a node, or empty string if unknown."""
146
+ return self._node_types.get(node_id, "")
147
+
148
+ def all_node_types(self) -> dict[str, str]:
149
+ """Return a copy of the node_id → type mapping."""
150
+ return dict(self._node_types)
151
+
152
+ def dirty_node_ids(self) -> frozenset[str]:
153
+ """Return the set of node IDs with changed properties since last mark_clean."""
154
+ return frozenset(self._dirty_nodes)
155
+
156
+ def mark_clean(self) -> None:
157
+ """Clear the dirty set."""
158
+ self._dirty_nodes.clear()
159
+
160
+ def register_property_callback(self, callback: PropertyCallback) -> Callable[[], None]:
161
+ """Register a callback fired on property value changes.
162
+
163
+ Callback signature: (node_id, prop_id, new_value, old_value).
164
+ Only fires when the value actually changes, not on every message.
165
+
166
+ Returns an unregister function.
167
+ """
168
+ self._property_callbacks.append(callback)
169
+
170
+ def unregister() -> None:
171
+ try:
172
+ self._property_callbacks.remove(callback)
173
+ except ValueError:
174
+ _LOGGER.debug("Callback already unregistered")
175
+
176
+ return unregister
177
+
178
+ # -- Internal handlers ---------------------------------------------------
179
+
180
+ def _handle_state(self, payload: str) -> None:
181
+ """Handle $state topic and drive lifecycle transitions."""
182
+ if payload == HOMIE_STATE_READY:
183
+ self._received_state_ready = True
184
+ if self._received_description:
185
+ self._transition_to_ready()
186
+ else:
187
+ # State ready but no description yet
188
+ if self._lifecycle == HomieLifecycle.DISCONNECTED:
189
+ self._lifecycle = HomieLifecycle.CONNECTED
190
+ elif payload in (HOMIE_STATE_DISCONNECTED, HOMIE_STATE_LOST):
191
+ self._lifecycle = HomieLifecycle.DISCONNECTED
192
+ self._received_state_ready = False
193
+ self._received_description = False
194
+ else:
195
+ # init, sleeping, alert, etc. — connected but not ready
196
+ if self._lifecycle == HomieLifecycle.DISCONNECTED:
197
+ self._lifecycle = HomieLifecycle.CONNECTED
198
+ self._received_state_ready = False
199
+
200
+ _LOGGER.debug("Homie $state: %s → lifecycle=%s", payload, self._lifecycle.value)
201
+
202
+ def _handle_description(self, payload: str) -> None:
203
+ """Parse $description JSON and extract node type mappings."""
204
+ try:
205
+ desc = json.loads(payload)
206
+ except json.JSONDecodeError:
207
+ _LOGGER.warning("Invalid $description JSON")
208
+ return
209
+
210
+ self._received_description = True
211
+ self._node_types.clear()
212
+
213
+ nodes = desc.get("nodes", {})
214
+ if isinstance(nodes, dict):
215
+ for node_id, node_def in nodes.items():
216
+ if isinstance(node_def, dict):
217
+ node_type = node_def.get("type", "")
218
+ if isinstance(node_type, str):
219
+ self._node_types[str(node_id)] = node_type
220
+
221
+ # Mark all known nodes dirty
222
+ self._dirty_nodes.update(self._node_types.keys())
223
+
224
+ _LOGGER.debug("Parsed $description with %d nodes", len(self._node_types))
225
+
226
+ # Lifecycle transition
227
+ if self._received_state_ready:
228
+ self._transition_to_ready()
229
+ elif self._lifecycle in (HomieLifecycle.DISCONNECTED, HomieLifecycle.CONNECTED):
230
+ self._lifecycle = HomieLifecycle.DESCRIPTION_RECEIVED
231
+
232
+ def _handle_property(self, node_id: str, prop_id: str, value: str) -> None:
233
+ """Handle a reported property value update."""
234
+ now_s = int(time.time())
235
+
236
+ if node_id not in self._property_values:
237
+ self._property_values[node_id] = {}
238
+ self._property_timestamps[node_id] = {}
239
+
240
+ old_value = self._property_values[node_id].get(prop_id)
241
+
242
+ if old_value == value:
243
+ return # no change — no dirty, no callbacks, no timestamp bump
244
+
245
+ self._property_values[node_id][prop_id] = value
246
+ self._property_timestamps[node_id][prop_id] = now_s
247
+ self._dirty_nodes.add(node_id)
248
+
249
+ self._fire_callbacks(node_id, prop_id, value, old_value)
250
+
251
+ def _handle_target(self, node_id: str, prop_id: str, value: str) -> None:
252
+ """Handle a $target property value."""
253
+ if node_id not in self._target_values:
254
+ self._target_values[node_id] = {}
255
+
256
+ old_target = self._target_values[node_id].get(prop_id)
257
+ if old_target == value:
258
+ return # no change
259
+
260
+ self._target_values[node_id][prop_id] = value
261
+ self._dirty_nodes.add(node_id)
262
+
263
+ def _transition_to_ready(self) -> None:
264
+ """Transition lifecycle to READY."""
265
+ self._lifecycle = HomieLifecycle.READY
266
+ self._ready_since = time.monotonic()
267
+
268
+ def _fire_callbacks(self, node_id: str, prop_id: str, value: str, old_value: str | None) -> None:
269
+ """Fire all registered property callbacks, catching exceptions."""
270
+ for cb in self._property_callbacks:
271
+ try:
272
+ cb(node_id, prop_id, value, old_value)
273
+ except Exception: # pylint: disable=broad-exception-caught
274
+ _LOGGER.debug("Property callback error for %s/%s", node_id, prop_id, exc_info=True)
@@ -0,0 +1,67 @@
1
+ """Flat-schema (data-model-version absent) adapter.
2
+
3
+ Composes the existing accumulator + consumer and owns the flat wire format:
4
+ a single Homie device whose node ids are circuit UUIDs and capability names.
5
+ Nothing outside this package constructs a flat-schema topic.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import TYPE_CHECKING
12
+
13
+ from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator
14
+ from span_panel_api_schema_0.const import PROPERTY_SET_TOPIC_FMT, TYPE_CORE, WILDCARD_TOPIC_FMT
15
+ from span_panel_api_schema_0.consumer import HomieDeviceConsumer
16
+ from span_panel_api_schema_0.field_metadata import build_field_metadata
17
+
18
+ if TYPE_CHECKING:
19
+ from span_panel_api.models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot
20
+
21
+
22
+ class SchemaZeroAdapter:
23
+ """Parser for the flat single-device schema (firmware r202603-r202627)."""
24
+
25
+ schema_major = "schema_0"
26
+ SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = (">=0", "<1.0")
27
+
28
+ def __init__(self, serial_number: str, panel_size: int) -> None:
29
+ self._serial_number = serial_number
30
+ self._accumulator = HomiePropertyAccumulator(serial_number)
31
+ self._consumer = HomieDeviceConsumer(self._accumulator, panel_size)
32
+
33
+ def topics_to_subscribe(self) -> list[str]:
34
+ return [WILDCARD_TOPIC_FMT.format(serial=self._serial_number)]
35
+
36
+ def handle_message(self, topic: str, payload: str) -> None:
37
+ self._consumer.handle_message(topic, payload)
38
+
39
+ def is_ready(self) -> bool:
40
+ return self._consumer.is_ready()
41
+
42
+ def build_snapshot(self) -> SpanPanelSnapshot:
43
+ return self._consumer.build_snapshot()
44
+
45
+ def build_field_metadata(self, schema_types: HomieSchemaTypes) -> dict[str, FieldMetadata]:
46
+ return build_field_metadata(schema_types)
47
+
48
+ def circuit_nodes_missing_names(self) -> list[str]:
49
+ return self._consumer.circuit_nodes_missing_names()
50
+
51
+ def find_node_by_type(self, type_str: str) -> str | None:
52
+ return self._consumer.find_node_by_type(type_str)
53
+
54
+ def set_circuit_relay_topic(self, circuit_id: str) -> str:
55
+ return PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=circuit_id, prop="relay")
56
+
57
+ def set_circuit_priority_topic(self, circuit_id: str) -> str:
58
+ return PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=circuit_id, prop="shed-priority")
59
+
60
+ def set_dominant_power_source_topic(self) -> str | None:
61
+ core_node = self._consumer.find_node_by_type(TYPE_CORE)
62
+ if core_node is None:
63
+ return None
64
+ return PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=core_node, prop="dominant-power-source")
65
+
66
+ def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]:
67
+ return self._consumer.register_property_callback(callback)
@@ -0,0 +1,83 @@
1
+ """Constants for the flat-schema (Homie v5) parsing implementation."""
2
+
3
+ # ---------------------------------------------------------------------------
4
+ # Provenance anchor — the schema revision every fact in this module was read
5
+ # from. `tests/test_schema_provenance.py` fails when a captured schema reports a
6
+ # different one, which is the only pre-release signal that this adapter has
7
+ # drifted from the wire it claims to parse.
8
+ #
9
+ # The field name is per-adapter, not per-bootstrap: flat firmware publishes
10
+ # `typesSchemaHash` over a `types` block, while parent/child renames it to
11
+ # `deviceClassesSchemaHash` over `deviceClasses` — the hash is renamed with the
12
+ # block it covers, so schema_1 declares its own.
13
+ #
14
+ # Content-derived, not build-derived: SPAN defines it as the SHA-256 of the
15
+ # canonicalized schema object and states the schema "may remain unchanged across
16
+ # multiple firmware releases". So it moves when the schema moves, not on every
17
+ # release — which is what makes it usable as an anchor rather than noise.
18
+ # ---------------------------------------------------------------------------
19
+ SCHEMA_ANCHOR_FIELD = "typesSchemaHash"
20
+ SCHEMA_ANCHOR = "sha256:d347556a07d98f40"
21
+ SCHEMA_ANCHOR_FIRMWARE = "spanos2/r202603/05"
22
+
23
+ # Homie v5 topic structure
24
+ HOMIE_VERSION = 5
25
+ HOMIE_DOMAIN = "ebus"
26
+ TOPIC_PREFIX = f"{HOMIE_DOMAIN}/{HOMIE_VERSION}"
27
+
28
+ # Topic patterns (serial_number substituted at runtime).
29
+ # The adapter subscribes with the wildcard and publishes with the set pattern;
30
+ # per-topic read formats are not needed because every message arrives through
31
+ # the one wildcard subscription.
32
+ PROPERTY_SET_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/{{node}}/{{prop}}/set"
33
+ WILDCARD_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/#"
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Homie type strings.
37
+ #
38
+ # Two namespaces that are easy to conflate and are NOT the same set:
39
+ #
40
+ # * the `types` block of GET /api/v2/homie/schema, which declares the
41
+ # properties, units and datatypes available to a type; and
42
+ # * the `type` string a node actually carries in its $description on the wire.
43
+ #
44
+ # Every constant below is a node type observed on the wire. The ones in the
45
+ # first group are also declared in the schema, so metadata lookup finds them
46
+ # directly. See tests/test_schema_provenance.py, which asserts that.
47
+ # ---------------------------------------------------------------------------
48
+ TYPE_CORE = "energy.ebus.device.distribution-enclosure.core"
49
+ TYPE_LUGS = "energy.ebus.device.lugs"
50
+ TYPE_CIRCUIT = "energy.ebus.device.circuit"
51
+ TYPE_BESS = "energy.ebus.device.bess"
52
+ TYPE_PV = "energy.ebus.device.pv"
53
+ TYPE_EVSE = "energy.ebus.device.evse"
54
+ TYPE_POWER_FLOWS = "energy.ebus.device.power-flows"
55
+
56
+ # Wire-only subtypes: real node types published by real firmware (confirmed
57
+ # against a live panel in 1eef0dc), but NOT declared in the schema's `types`
58
+ # block, which carries only the base `energy.ebus.device.lugs`. Firmware uses
59
+ # one convention or the other — typed nodes, or generic nodes plus a
60
+ # `direction` property — and _find_lugs_node handles both.
61
+ #
62
+ # Because the schema does not declare them, every one of these needs an entry
63
+ # in field_metadata._LUGS_FALLBACK mapping it to a declared type, or property
64
+ # metadata silently comes back empty for those nodes. The provenance test
65
+ # asserts that pairing rather than trusting it.
66
+ TYPE_LUGS_UPSTREAM = "energy.ebus.device.lugs.upstream"
67
+ TYPE_LUGS_DOWNSTREAM = "energy.ebus.device.lugs.downstream"
68
+
69
+ # Lugs direction values
70
+ LUGS_UPSTREAM = "UPSTREAM"
71
+ LUGS_DOWNSTREAM = "DOWNSTREAM"
72
+
73
+
74
+ def normalize_circuit_id(node_id: str) -> str:
75
+ """Strip dashes from Homie UUID for entity stability."""
76
+ return node_id.replace("-", "")
77
+
78
+
79
+ def denormalize_circuit_id(circuit_id: str) -> str:
80
+ """Restore dashes to a 32-char dashless UUID (8-4-4-4-12 format)."""
81
+ if len(circuit_id) == 32 and "-" not in circuit_id:
82
+ return f"{circuit_id[:8]}-{circuit_id[8:12]}-{circuit_id[12:16]}-{circuit_id[16:20]}-{circuit_id[20:]}"
83
+ return circuit_id