span-panel-api-schema-0 1.0.0b1__py3-none-any.whl
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_schema_0/__init__.py +11 -0
- span_panel_api_schema_0/accumulator.py +274 -0
- span_panel_api_schema_0/adapter.py +67 -0
- span_panel_api_schema_0/const.py +83 -0
- span_panel_api_schema_0/consumer.py +641 -0
- span_panel_api_schema_0/field_metadata.py +176 -0
- span_panel_api_schema_0/py.typed +0 -0
- span_panel_api_schema_0-1.0.0b1.dist-info/METADATA +44 -0
- span_panel_api_schema_0-1.0.0b1.dist-info/RECORD +11 -0
- span_panel_api_schema_0-1.0.0b1.dist-info/WHEEL +4 -0
- span_panel_api_schema_0-1.0.0b1.dist-info/entry_points.txt +2 -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
|
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
"""Homie v5 device consumer for SPAN Panel.
|
|
2
|
+
|
|
3
|
+
Builds transport-agnostic SpanPanelSnapshot from the accumulated
|
|
4
|
+
Homie property state stored in a HomiePropertyAccumulator.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
import dataclasses
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from typing import ClassVar
|
|
14
|
+
|
|
15
|
+
from span_panel_api.models import (
|
|
16
|
+
SpanBatterySnapshot,
|
|
17
|
+
SpanCircuitSnapshot,
|
|
18
|
+
SpanEvseSnapshot,
|
|
19
|
+
SpanPanelSnapshot,
|
|
20
|
+
SpanPVSnapshot,
|
|
21
|
+
)
|
|
22
|
+
from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator
|
|
23
|
+
from span_panel_api_schema_0.const import (
|
|
24
|
+
LUGS_DOWNSTREAM,
|
|
25
|
+
LUGS_UPSTREAM,
|
|
26
|
+
TYPE_BESS,
|
|
27
|
+
TYPE_CIRCUIT,
|
|
28
|
+
TYPE_CORE,
|
|
29
|
+
TYPE_EVSE,
|
|
30
|
+
TYPE_LUGS,
|
|
31
|
+
TYPE_LUGS_DOWNSTREAM,
|
|
32
|
+
TYPE_LUGS_UPSTREAM,
|
|
33
|
+
TYPE_POWER_FLOWS,
|
|
34
|
+
TYPE_PV,
|
|
35
|
+
normalize_circuit_id,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_LOGGER = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
# Threshold below which grid power is considered "not exchanging" when no
|
|
41
|
+
# authoritative bess/grid-state is available. Real lugs readings never land
|
|
42
|
+
# exactly on 0.0; 1 W is well below sensor noise.
|
|
43
|
+
_GRID_POWER_EPSILON_W = 1.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse_bool(value: str) -> bool:
|
|
47
|
+
"""Parse a Homie boolean string."""
|
|
48
|
+
return value.lower() == "true"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_float(value: str, default: float = 0.0) -> float:
|
|
52
|
+
"""Parse a float string, returning default on failure."""
|
|
53
|
+
try:
|
|
54
|
+
return float(value)
|
|
55
|
+
except (ValueError, TypeError):
|
|
56
|
+
return default
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse_int(value: str, default: int = 0) -> int:
|
|
60
|
+
"""Parse an integer string, returning default on failure."""
|
|
61
|
+
try:
|
|
62
|
+
return int(value)
|
|
63
|
+
except (ValueError, TypeError):
|
|
64
|
+
return default
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class HomieDeviceConsumer:
|
|
68
|
+
"""Build SPAN-specific snapshots from accumulated Homie property state.
|
|
69
|
+
|
|
70
|
+
All methods must be called from the asyncio event loop thread
|
|
71
|
+
(guaranteed by AsyncMqttBridge's call_soon_threadsafe dispatch).
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, accumulator: HomiePropertyAccumulator, panel_size: int) -> None:
|
|
75
|
+
self._acc = accumulator
|
|
76
|
+
self._panel_size = panel_size
|
|
77
|
+
self._cached_snapshot: SpanPanelSnapshot | None = None
|
|
78
|
+
|
|
79
|
+
# -- Delegation to accumulator -------------------------------------------
|
|
80
|
+
# These thin wrappers allow SpanMqttClient (and legacy test code) to
|
|
81
|
+
# continue calling is_ready / handle_message / find_node_by_type /
|
|
82
|
+
# register_property_callback on the consumer directly, without requiring
|
|
83
|
+
# callers to hold a separate reference to the accumulator.
|
|
84
|
+
|
|
85
|
+
def is_ready(self) -> bool:
|
|
86
|
+
"""Delegate to accumulator.is_ready()."""
|
|
87
|
+
return self._acc.is_ready()
|
|
88
|
+
|
|
89
|
+
def handle_message(self, topic: str, payload: str) -> None:
|
|
90
|
+
"""Delegate to accumulator.handle_message()."""
|
|
91
|
+
self._acc.handle_message(topic, payload)
|
|
92
|
+
|
|
93
|
+
def find_node_by_type(self, type_str: str) -> str | None:
|
|
94
|
+
"""Delegate to accumulator.find_node_by_type()."""
|
|
95
|
+
return self._acc.find_node_by_type(type_str)
|
|
96
|
+
|
|
97
|
+
def register_property_callback(
|
|
98
|
+
self,
|
|
99
|
+
callback: Callable[[str, str, str, str | None], None],
|
|
100
|
+
) -> Callable[[], None]:
|
|
101
|
+
"""Delegate to accumulator.register_property_callback()."""
|
|
102
|
+
return self._acc.register_property_callback(callback)
|
|
103
|
+
|
|
104
|
+
def circuit_nodes_missing_names(self) -> list[str]:
|
|
105
|
+
"""Return circuit-like node IDs that have no ``name`` property yet."""
|
|
106
|
+
missing: list[str] = []
|
|
107
|
+
for node_id in self._acc.nodes_by_type(TYPE_CIRCUIT):
|
|
108
|
+
if not self._acc.get_prop(node_id, "name"):
|
|
109
|
+
missing.append(node_id)
|
|
110
|
+
return missing
|
|
111
|
+
|
|
112
|
+
# Node types that affect panel-level snapshot fields.
|
|
113
|
+
# Any dirty node of these types triggers a full rebuild.
|
|
114
|
+
_PANEL_LEVEL_TYPES: ClassVar[frozenset[str]] = frozenset(
|
|
115
|
+
{
|
|
116
|
+
TYPE_CORE,
|
|
117
|
+
TYPE_LUGS,
|
|
118
|
+
TYPE_LUGS_UPSTREAM,
|
|
119
|
+
TYPE_LUGS_DOWNSTREAM,
|
|
120
|
+
TYPE_BESS,
|
|
121
|
+
TYPE_PV,
|
|
122
|
+
TYPE_EVSE,
|
|
123
|
+
TYPE_POWER_FLOWS,
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def build_snapshot(self) -> SpanPanelSnapshot:
|
|
128
|
+
"""Build a point-in-time snapshot, using cache when possible.
|
|
129
|
+
|
|
130
|
+
Must be called after accumulator is_ready() returns True.
|
|
131
|
+
"""
|
|
132
|
+
dirty = self._acc.dirty_node_ids()
|
|
133
|
+
|
|
134
|
+
if not dirty and self._cached_snapshot is not None:
|
|
135
|
+
self._acc.mark_clean()
|
|
136
|
+
return self._cached_snapshot
|
|
137
|
+
|
|
138
|
+
node_types = self._acc.all_node_types()
|
|
139
|
+
needs_full = self._cached_snapshot is None or any(
|
|
140
|
+
node_types.get(nid, "") in self._PANEL_LEVEL_TYPES or nid not in node_types for nid in dirty
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
snapshot = self._build_snapshot() if needs_full else self._rebuild_dirty_circuits(dirty)
|
|
144
|
+
self._cached_snapshot = snapshot
|
|
145
|
+
self._acc.mark_clean()
|
|
146
|
+
return snapshot
|
|
147
|
+
|
|
148
|
+
def _rebuild_dirty_circuits(self, dirty: frozenset[str]) -> SpanPanelSnapshot:
|
|
149
|
+
"""Partial rebuild — only rebuild circuits whose nodes are dirty."""
|
|
150
|
+
if self._cached_snapshot is None:
|
|
151
|
+
raise RuntimeError("_rebuild_dirty_circuits called without a cached snapshot")
|
|
152
|
+
cached = self._cached_snapshot
|
|
153
|
+
|
|
154
|
+
feed_metadata = self._build_feed_metadata()
|
|
155
|
+
updated_circuits: dict[str, SpanCircuitSnapshot] = {}
|
|
156
|
+
# Keep only non-unmapped circuits from cache, rebuild dirty ones
|
|
157
|
+
for cid, circ in cached.circuits.items():
|
|
158
|
+
if cid.startswith("unmapped_tab_"):
|
|
159
|
+
continue # drop old unmapped entries; will recompute below
|
|
160
|
+
updated_circuits[cid] = circ
|
|
161
|
+
for node_id in dirty:
|
|
162
|
+
if self._is_circuit_node(node_id):
|
|
163
|
+
meta = feed_metadata.get(node_id, {})
|
|
164
|
+
device_type = meta.get("device_type", "circuit")
|
|
165
|
+
relative_position = meta.get("relative_position", "")
|
|
166
|
+
circuit = self._build_circuit(node_id, device_type, relative_position)
|
|
167
|
+
updated_circuits[circuit.circuit_id] = circuit
|
|
168
|
+
|
|
169
|
+
# Recompute unmapped tabs based on current circuit set
|
|
170
|
+
unmapped = self._build_unmapped_tabs(updated_circuits)
|
|
171
|
+
updated_circuits.update(unmapped)
|
|
172
|
+
|
|
173
|
+
return dataclasses.replace(cached, circuits=updated_circuits)
|
|
174
|
+
|
|
175
|
+
def _find_lugs_node(self, direction: str) -> str | None:
|
|
176
|
+
"""Find the lugs node with a specific direction.
|
|
177
|
+
|
|
178
|
+
Handles two firmware conventions:
|
|
179
|
+
- Typed: node type is ``energy.ebus.device.lugs.upstream`` / ``.downstream``
|
|
180
|
+
- Generic: node type is ``energy.ebus.device.lugs`` with a ``direction`` property
|
|
181
|
+
"""
|
|
182
|
+
# Typed variant (direction embedded in the type string)
|
|
183
|
+
typed_map = {
|
|
184
|
+
LUGS_UPSTREAM: TYPE_LUGS_UPSTREAM,
|
|
185
|
+
LUGS_DOWNSTREAM: TYPE_LUGS_DOWNSTREAM,
|
|
186
|
+
}
|
|
187
|
+
target_type = typed_map.get(direction)
|
|
188
|
+
if target_type:
|
|
189
|
+
node_types = self._acc.all_node_types()
|
|
190
|
+
for node_id, node_type in node_types.items():
|
|
191
|
+
if node_type == target_type:
|
|
192
|
+
return node_id
|
|
193
|
+
|
|
194
|
+
# Generic variant (single TYPE_LUGS with direction property)
|
|
195
|
+
node_types = self._acc.all_node_types()
|
|
196
|
+
for node_id, node_type in node_types.items():
|
|
197
|
+
if node_type == TYPE_LUGS:
|
|
198
|
+
prop_dir = self._acc.get_prop(node_id, "direction")
|
|
199
|
+
if prop_dir.upper() == direction:
|
|
200
|
+
return node_id
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
# Only TYPE_CIRCUIT nodes have the full MQTT property schema (power,
|
|
204
|
+
# energy, relay, space, etc.). PV and EVSE nodes are metadata-only
|
|
205
|
+
# (feed, nameplate-capacity, vendor-name) and reference the physical
|
|
206
|
+
# circuit via their ``feed`` property.
|
|
207
|
+
_CIRCUIT_LIKE_TYPES: frozenset[str] = frozenset({TYPE_CIRCUIT})
|
|
208
|
+
|
|
209
|
+
# Metadata node types whose ``feed`` property points to a circuit,
|
|
210
|
+
# annotating it with a device_type.
|
|
211
|
+
_FEED_TYPE_MAP: ClassVar[dict[str, str]] = {
|
|
212
|
+
TYPE_PV: "pv",
|
|
213
|
+
TYPE_EVSE: "evse",
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
def _is_circuit_node(self, node_id: str) -> bool:
|
|
217
|
+
"""Check if node is a circuit device."""
|
|
218
|
+
return self._acc.get_node_type(node_id) in self._CIRCUIT_LIKE_TYPES
|
|
219
|
+
|
|
220
|
+
def _build_feed_metadata(self) -> dict[str, dict[str, str]]:
|
|
221
|
+
"""Build mapping of circuit node_id → metadata from PV/EVSE feed references.
|
|
222
|
+
|
|
223
|
+
Returns dict keyed by circuit node_id with values containing:
|
|
224
|
+
- device_type: "pv" | "evse"
|
|
225
|
+
- relative_position: "IN_PANEL" | "UPSTREAM" | "DOWNSTREAM" | ""
|
|
226
|
+
"""
|
|
227
|
+
feed_meta: dict[str, dict[str, str]] = {}
|
|
228
|
+
for node_id, node_type in self._acc.all_node_types().items():
|
|
229
|
+
device_type = self._FEED_TYPE_MAP.get(node_type)
|
|
230
|
+
if device_type:
|
|
231
|
+
feed_circuit = self._acc.get_prop(node_id, "feed")
|
|
232
|
+
if feed_circuit:
|
|
233
|
+
rel_pos = self._acc.get_prop(node_id, "relative-position")
|
|
234
|
+
feed_meta[feed_circuit] = {
|
|
235
|
+
"device_type": device_type,
|
|
236
|
+
"relative_position": rel_pos.upper() if rel_pos else "",
|
|
237
|
+
}
|
|
238
|
+
return feed_meta
|
|
239
|
+
|
|
240
|
+
def _build_circuit(self, node_id: str, device_type: str = "circuit", relative_position: str = "") -> SpanCircuitSnapshot:
|
|
241
|
+
"""Build a circuit snapshot from accumulated properties."""
|
|
242
|
+
circuit_id = normalize_circuit_id(node_id)
|
|
243
|
+
|
|
244
|
+
# active-power is in watts; negate so positive = consumption.
|
|
245
|
+
# Guard against -0.0 creeping in when raw_power_w is 0.0.
|
|
246
|
+
raw_power_w = _parse_float(self._acc.get_prop(node_id, "active-power"))
|
|
247
|
+
instant_power_w = 0.0 if raw_power_w == 0.0 else -raw_power_w
|
|
248
|
+
|
|
249
|
+
# Energy: exported-energy = consumption (panel exports TO circuit)
|
|
250
|
+
consumed_wh = _parse_float(self._acc.get_prop(node_id, "exported-energy"))
|
|
251
|
+
# imported-energy = production (panel imports FROM circuit)
|
|
252
|
+
produced_wh = _parse_float(self._acc.get_prop(node_id, "imported-energy"))
|
|
253
|
+
|
|
254
|
+
# Tabs: derived from space + dipole
|
|
255
|
+
# Dipole circuits occupy two consecutive spaces on the same bus bar
|
|
256
|
+
# side: [space, space + 2] (odd+odd or even+even)
|
|
257
|
+
space_val = self._acc.get_prop(node_id, "space")
|
|
258
|
+
is_dipole = _parse_bool(self._acc.get_prop(node_id, "dipole"))
|
|
259
|
+
tabs: list[int] = []
|
|
260
|
+
if space_val:
|
|
261
|
+
space = _parse_int(space_val)
|
|
262
|
+
tabs = [space, space + 2] if is_dipole else [space]
|
|
263
|
+
|
|
264
|
+
always_on = _parse_bool(self._acc.get_prop(node_id, "always-on"))
|
|
265
|
+
|
|
266
|
+
# Timestamps from MQTT arrival time
|
|
267
|
+
energy_ts = max(
|
|
268
|
+
self._acc.get_timestamp(node_id, "exported-energy"),
|
|
269
|
+
self._acc.get_timestamp(node_id, "imported-energy"),
|
|
270
|
+
)
|
|
271
|
+
power_ts = self._acc.get_timestamp(node_id, "active-power")
|
|
272
|
+
|
|
273
|
+
return SpanCircuitSnapshot(
|
|
274
|
+
circuit_id=circuit_id,
|
|
275
|
+
name=self._acc.get_prop(node_id, "name"),
|
|
276
|
+
relay_state=self._acc.get_prop(node_id, "relay", "UNKNOWN"),
|
|
277
|
+
instant_power_w=instant_power_w,
|
|
278
|
+
produced_energy_wh=produced_wh,
|
|
279
|
+
consumed_energy_wh=consumed_wh,
|
|
280
|
+
tabs=tabs,
|
|
281
|
+
priority=self._acc.get_prop(node_id, "shed-priority", "UNKNOWN"),
|
|
282
|
+
is_user_controllable=not always_on,
|
|
283
|
+
is_sheddable=_parse_bool(self._acc.get_prop(node_id, "sheddable")),
|
|
284
|
+
is_never_backup=_parse_bool(self._acc.get_prop(node_id, "never-backup")),
|
|
285
|
+
device_type=device_type,
|
|
286
|
+
relative_position=relative_position,
|
|
287
|
+
is_240v=_parse_bool(self._acc.get_prop(node_id, "dipole")),
|
|
288
|
+
current_a=(
|
|
289
|
+
_parse_float(self._acc.get_prop(node_id, "current")) if self._acc.get_prop(node_id, "current") else None
|
|
290
|
+
),
|
|
291
|
+
breaker_rating_a=(
|
|
292
|
+
_parse_float(self._acc.get_prop(node_id, "breaker-rating"))
|
|
293
|
+
if self._acc.get_prop(node_id, "breaker-rating")
|
|
294
|
+
else None
|
|
295
|
+
),
|
|
296
|
+
always_on=always_on,
|
|
297
|
+
relay_requester=self._acc.get_prop(node_id, "relay-requester", "UNKNOWN"),
|
|
298
|
+
energy_accum_update_time_s=energy_ts,
|
|
299
|
+
instant_power_update_time_s=power_ts,
|
|
300
|
+
relay_state_target=self._acc.get_target(node_id, "relay"),
|
|
301
|
+
priority_target=self._acc.get_target(node_id, "shed-priority"),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def _build_battery(self) -> SpanBatterySnapshot:
|
|
305
|
+
"""Build battery snapshot from BESS node."""
|
|
306
|
+
bess_node = self._acc.find_node_by_type(TYPE_BESS)
|
|
307
|
+
if bess_node is None:
|
|
308
|
+
return SpanBatterySnapshot()
|
|
309
|
+
|
|
310
|
+
soc_str = self._acc.get_prop(bess_node, "soc")
|
|
311
|
+
soe_str = self._acc.get_prop(bess_node, "soe")
|
|
312
|
+
|
|
313
|
+
vn = self._acc.get_prop(bess_node, "vendor-name")
|
|
314
|
+
pn = self._acc.get_prop(bess_node, "product-name")
|
|
315
|
+
mdl = self._acc.get_prop(bess_node, "model")
|
|
316
|
+
sn = self._acc.get_prop(bess_node, "serial-number")
|
|
317
|
+
sw = self._acc.get_prop(bess_node, "software-version")
|
|
318
|
+
nc = self._acc.get_prop(bess_node, "nameplate-capacity")
|
|
319
|
+
conn = self._acc.get_prop(bess_node, "connected")
|
|
320
|
+
|
|
321
|
+
return SpanBatterySnapshot(
|
|
322
|
+
soe_percentage=_parse_float(soc_str) if soc_str else None,
|
|
323
|
+
soe_kwh=_parse_float(soe_str) if soe_str else None,
|
|
324
|
+
vendor_name=vn if vn else None,
|
|
325
|
+
product_name=pn if pn else None,
|
|
326
|
+
model=mdl if mdl else None,
|
|
327
|
+
serial_number=sn if sn else None,
|
|
328
|
+
software_version=sw if sw else None,
|
|
329
|
+
nameplate_capacity_kwh=_parse_float(nc) if nc else None,
|
|
330
|
+
connected=conn.lower() == "true" if conn else None,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def _build_pv(self) -> SpanPVSnapshot:
|
|
334
|
+
"""Build PV snapshot from the first PV metadata node."""
|
|
335
|
+
pv_node = self._acc.find_node_by_type(TYPE_PV)
|
|
336
|
+
if pv_node is None:
|
|
337
|
+
return SpanPVSnapshot()
|
|
338
|
+
|
|
339
|
+
vn = self._acc.get_prop(pv_node, "vendor-name")
|
|
340
|
+
pn = self._acc.get_prop(pv_node, "product-name")
|
|
341
|
+
nc = self._acc.get_prop(pv_node, "nameplate-capacity")
|
|
342
|
+
feed = self._acc.get_prop(pv_node, "feed")
|
|
343
|
+
rel_pos = self._acc.get_prop(pv_node, "relative-position")
|
|
344
|
+
|
|
345
|
+
return SpanPVSnapshot(
|
|
346
|
+
vendor_name=vn if vn else None,
|
|
347
|
+
product_name=pn if pn else None,
|
|
348
|
+
nameplate_capacity_w=_parse_float(nc) if nc else None,
|
|
349
|
+
feed_circuit_id=normalize_circuit_id(feed) if feed else None,
|
|
350
|
+
relative_position=rel_pos.upper() if rel_pos else None,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def _build_evse_devices(self) -> dict[str, SpanEvseSnapshot]:
|
|
354
|
+
"""Build EVSE snapshots from all EVSE metadata nodes."""
|
|
355
|
+
result: dict[str, SpanEvseSnapshot] = {}
|
|
356
|
+
for node_id, node_type in self._acc.all_node_types().items():
|
|
357
|
+
if node_type != TYPE_EVSE:
|
|
358
|
+
continue
|
|
359
|
+
feed = self._acc.get_prop(node_id, "feed")
|
|
360
|
+
if not feed:
|
|
361
|
+
continue
|
|
362
|
+
adv = self._acc.get_prop(node_id, "advertised-current")
|
|
363
|
+
result[node_id] = SpanEvseSnapshot(
|
|
364
|
+
node_id=node_id,
|
|
365
|
+
feed_circuit_id=normalize_circuit_id(feed),
|
|
366
|
+
status=self._acc.get_prop(node_id, "status") or "UNKNOWN",
|
|
367
|
+
lock_state=self._acc.get_prop(node_id, "lock-state") or "UNKNOWN",
|
|
368
|
+
advertised_current_a=_parse_float(adv) if adv else None,
|
|
369
|
+
vendor_name=self._acc.get_prop(node_id, "vendor-name") or None,
|
|
370
|
+
product_name=self._acc.get_prop(node_id, "product-name") or None,
|
|
371
|
+
part_number=self._acc.get_prop(node_id, "part-number") or None,
|
|
372
|
+
serial_number=self._acc.get_prop(node_id, "serial-number") or None,
|
|
373
|
+
software_version=self._acc.get_prop(node_id, "software-version") or None,
|
|
374
|
+
)
|
|
375
|
+
return result
|
|
376
|
+
|
|
377
|
+
def _derive_dsm_state(self, core_node: str | None, grid_power: float, power_flow_grid: float | None) -> str:
|
|
378
|
+
"""Derive dsm_state from multiple signals.
|
|
379
|
+
|
|
380
|
+
Priority:
|
|
381
|
+
1. bess/grid-state — authoritative when BESS is commissioned
|
|
382
|
+
2. dominant-power-source == GRID — grid is the primary source
|
|
383
|
+
3. grid_power or power_flow_grid non-zero — grid exchanging power
|
|
384
|
+
4. both grid signals zero AND DPS != GRID — islanded
|
|
385
|
+
"""
|
|
386
|
+
# 1. BESS grid-state is authoritative when available
|
|
387
|
+
bess_node = self._acc.find_node_by_type(TYPE_BESS)
|
|
388
|
+
if bess_node is not None:
|
|
389
|
+
gs = self._acc.get_prop(bess_node, "grid-state")
|
|
390
|
+
if gs == "ON_GRID":
|
|
391
|
+
return "DSM_ON_GRID"
|
|
392
|
+
if gs == "OFF_GRID":
|
|
393
|
+
return "DSM_OFF_GRID"
|
|
394
|
+
|
|
395
|
+
# 2-4. Fallback heuristic using DPS and grid power signals
|
|
396
|
+
if core_node is not None:
|
|
397
|
+
dps = self._acc.get_prop(core_node, "dominant-power-source")
|
|
398
|
+
if dps == "GRID":
|
|
399
|
+
return "DSM_ON_GRID"
|
|
400
|
+
|
|
401
|
+
if dps in ("BATTERY", "PV", "GENERATOR"):
|
|
402
|
+
grid_exchanging = abs(grid_power) > _GRID_POWER_EPSILON_W or (
|
|
403
|
+
power_flow_grid is not None and abs(power_flow_grid) > _GRID_POWER_EPSILON_W
|
|
404
|
+
)
|
|
405
|
+
return "DSM_ON_GRID" if grid_exchanging else "DSM_OFF_GRID"
|
|
406
|
+
|
|
407
|
+
return "UNKNOWN"
|
|
408
|
+
|
|
409
|
+
def _derive_run_config(self, dsm_state: str, grid_islandable: bool | None, dps: str | None) -> str:
|
|
410
|
+
"""Derive current_run_config from grid state, islandability, and power source.
|
|
411
|
+
|
|
412
|
+
Decision table:
|
|
413
|
+
- DSM_ON_GRID → PANEL_ON_GRID (regardless of islandable)
|
|
414
|
+
- DSM_OFF_GRID + islandable + BATTERY → PANEL_BACKUP
|
|
415
|
+
- DSM_OFF_GRID + islandable + PV/GENERATOR → PANEL_OFF_GRID
|
|
416
|
+
- DSM_OFF_GRID + islandable + other → UNKNOWN
|
|
417
|
+
- DSM_OFF_GRID + not islandable → UNKNOWN (shouldn't happen)
|
|
418
|
+
"""
|
|
419
|
+
if dsm_state == "DSM_ON_GRID":
|
|
420
|
+
return "PANEL_ON_GRID"
|
|
421
|
+
|
|
422
|
+
if dsm_state == "DSM_OFF_GRID":
|
|
423
|
+
if not grid_islandable:
|
|
424
|
+
return "UNKNOWN"
|
|
425
|
+
if dps == "BATTERY":
|
|
426
|
+
return "PANEL_BACKUP"
|
|
427
|
+
if dps in ("PV", "GENERATOR"):
|
|
428
|
+
return "PANEL_OFF_GRID"
|
|
429
|
+
return "UNKNOWN"
|
|
430
|
+
|
|
431
|
+
return "UNKNOWN"
|
|
432
|
+
|
|
433
|
+
def _build_unmapped_tabs(
|
|
434
|
+
self,
|
|
435
|
+
circuits: dict[str, SpanCircuitSnapshot],
|
|
436
|
+
) -> dict[str, SpanCircuitSnapshot]:
|
|
437
|
+
"""Synthesize unmapped tab entries for breaker positions with no circuit.
|
|
438
|
+
|
|
439
|
+
Creates zero-power SpanCircuitSnapshot entries for unoccupied positions
|
|
440
|
+
up to ``self._panel_size``.
|
|
441
|
+
"""
|
|
442
|
+
occupied_tabs: set[int] = set()
|
|
443
|
+
for circuit in circuits.values():
|
|
444
|
+
occupied_tabs.update(circuit.tabs)
|
|
445
|
+
|
|
446
|
+
unmapped: dict[str, SpanCircuitSnapshot] = {}
|
|
447
|
+
for tab in range(1, self._panel_size + 1):
|
|
448
|
+
if tab not in occupied_tabs:
|
|
449
|
+
circuit_id = f"unmapped_tab_{tab}"
|
|
450
|
+
unmapped[circuit_id] = SpanCircuitSnapshot(
|
|
451
|
+
circuit_id=circuit_id,
|
|
452
|
+
name=f"Unmapped Tab {tab}",
|
|
453
|
+
relay_state="CLOSED",
|
|
454
|
+
instant_power_w=0.0,
|
|
455
|
+
produced_energy_wh=0.0,
|
|
456
|
+
consumed_energy_wh=0.0,
|
|
457
|
+
tabs=[tab],
|
|
458
|
+
priority="UNKNOWN",
|
|
459
|
+
is_user_controllable=False,
|
|
460
|
+
is_sheddable=False,
|
|
461
|
+
is_never_backup=False,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
return unmapped
|
|
465
|
+
|
|
466
|
+
def _build_snapshot(self) -> SpanPanelSnapshot:
|
|
467
|
+
"""Build full snapshot from accumulated property values."""
|
|
468
|
+
core_node = self._acc.find_node_by_type(TYPE_CORE)
|
|
469
|
+
upstream_lugs = self._find_lugs_node(LUGS_UPSTREAM)
|
|
470
|
+
downstream_lugs = self._find_lugs_node(LUGS_DOWNSTREAM)
|
|
471
|
+
|
|
472
|
+
# Core properties
|
|
473
|
+
firmware = ""
|
|
474
|
+
door_state = "UNKNOWN"
|
|
475
|
+
main_relay = "UNKNOWN"
|
|
476
|
+
eth0 = False
|
|
477
|
+
wlan = False
|
|
478
|
+
wwan_connected = False
|
|
479
|
+
dominant_power_source: str | None = None
|
|
480
|
+
grid_islandable: bool | None = None
|
|
481
|
+
l1_voltage: float | None = None
|
|
482
|
+
l2_voltage: float | None = None
|
|
483
|
+
main_breaker: int | None = None
|
|
484
|
+
wifi_ssid: str | None = None
|
|
485
|
+
vendor_cloud: str | None = None
|
|
486
|
+
|
|
487
|
+
if core_node is not None:
|
|
488
|
+
firmware = self._acc.get_prop(core_node, "software-version")
|
|
489
|
+
door_state = self._acc.get_prop(core_node, "door", "UNKNOWN")
|
|
490
|
+
main_relay = self._acc.get_prop(core_node, "relay", "UNKNOWN")
|
|
491
|
+
eth0 = _parse_bool(self._acc.get_prop(core_node, "ethernet"))
|
|
492
|
+
wlan = _parse_bool(self._acc.get_prop(core_node, "wifi"))
|
|
493
|
+
|
|
494
|
+
vc = self._acc.get_prop(core_node, "vendor-cloud")
|
|
495
|
+
wwan_connected = vc == "CONNECTED"
|
|
496
|
+
vendor_cloud = vc if vc else None
|
|
497
|
+
|
|
498
|
+
dps = self._acc.get_prop(core_node, "dominant-power-source")
|
|
499
|
+
dominant_power_source = dps if dps else None
|
|
500
|
+
|
|
501
|
+
gi = self._acc.get_prop(core_node, "grid-islandable")
|
|
502
|
+
grid_islandable = _parse_bool(gi) if gi else None
|
|
503
|
+
|
|
504
|
+
l1v = self._acc.get_prop(core_node, "l1-voltage")
|
|
505
|
+
l1_voltage = _parse_float(l1v) if l1v else None
|
|
506
|
+
|
|
507
|
+
l2v = self._acc.get_prop(core_node, "l2-voltage")
|
|
508
|
+
l2_voltage = _parse_float(l2v) if l2v else None
|
|
509
|
+
|
|
510
|
+
br = self._acc.get_prop(core_node, "breaker-rating")
|
|
511
|
+
main_breaker = _parse_int(br) if br else None
|
|
512
|
+
|
|
513
|
+
ws = self._acc.get_prop(core_node, "wifi-ssid")
|
|
514
|
+
wifi_ssid = ws if ws else None
|
|
515
|
+
|
|
516
|
+
# Upstream lugs → main meter (grid connection)
|
|
517
|
+
# imported-energy = energy imported from the grid = consumed by the house
|
|
518
|
+
# exported-energy = energy exported to the grid = produced (solar)
|
|
519
|
+
grid_power = 0.0
|
|
520
|
+
main_consumed = 0.0
|
|
521
|
+
main_produced = 0.0
|
|
522
|
+
upstream_l1_current: float | None = None
|
|
523
|
+
upstream_l2_current: float | None = None
|
|
524
|
+
if upstream_lugs is not None:
|
|
525
|
+
grid_power = _parse_float(self._acc.get_prop(upstream_lugs, "active-power"))
|
|
526
|
+
main_consumed = _parse_float(self._acc.get_prop(upstream_lugs, "imported-energy"))
|
|
527
|
+
main_produced = _parse_float(self._acc.get_prop(upstream_lugs, "exported-energy"))
|
|
528
|
+
|
|
529
|
+
l1_i = self._acc.get_prop(upstream_lugs, "l1-current")
|
|
530
|
+
upstream_l1_current = _parse_float(l1_i) if l1_i else None
|
|
531
|
+
l2_i = self._acc.get_prop(upstream_lugs, "l2-current")
|
|
532
|
+
upstream_l2_current = _parse_float(l2_i) if l2_i else None
|
|
533
|
+
|
|
534
|
+
# Downstream lugs → feedthrough
|
|
535
|
+
feedthrough_power = 0.0
|
|
536
|
+
feedthrough_consumed = 0.0
|
|
537
|
+
feedthrough_produced = 0.0
|
|
538
|
+
downstream_l1_current: float | None = None
|
|
539
|
+
downstream_l2_current: float | None = None
|
|
540
|
+
if downstream_lugs is not None:
|
|
541
|
+
feedthrough_power = _parse_float(self._acc.get_prop(downstream_lugs, "active-power"))
|
|
542
|
+
feedthrough_consumed = _parse_float(self._acc.get_prop(downstream_lugs, "imported-energy"))
|
|
543
|
+
feedthrough_produced = _parse_float(self._acc.get_prop(downstream_lugs, "exported-energy"))
|
|
544
|
+
|
|
545
|
+
dl1_i = self._acc.get_prop(downstream_lugs, "l1-current")
|
|
546
|
+
downstream_l1_current = _parse_float(dl1_i) if dl1_i else None
|
|
547
|
+
dl2_i = self._acc.get_prop(downstream_lugs, "l2-current")
|
|
548
|
+
downstream_l2_current = _parse_float(dl2_i) if dl2_i else None
|
|
549
|
+
|
|
550
|
+
# Power flows
|
|
551
|
+
pf_node = self._acc.find_node_by_type(TYPE_POWER_FLOWS)
|
|
552
|
+
power_flow_pv: float | None = None
|
|
553
|
+
power_flow_battery: float | None = None
|
|
554
|
+
power_flow_grid: float | None = None
|
|
555
|
+
power_flow_site: float | None = None
|
|
556
|
+
if pf_node is not None:
|
|
557
|
+
pf_pv = self._acc.get_prop(pf_node, "pv")
|
|
558
|
+
power_flow_pv = _parse_float(pf_pv) if pf_pv else None
|
|
559
|
+
pf_bat = self._acc.get_prop(pf_node, "battery")
|
|
560
|
+
power_flow_battery = _parse_float(pf_bat) if pf_bat else None
|
|
561
|
+
pf_grid = self._acc.get_prop(pf_node, "grid")
|
|
562
|
+
power_flow_grid = _parse_float(pf_grid) if pf_grid else None
|
|
563
|
+
pf_site = self._acc.get_prop(pf_node, "site")
|
|
564
|
+
power_flow_site = _parse_float(pf_site) if pf_site else None
|
|
565
|
+
|
|
566
|
+
# Build metadata annotations from PV/EVSE metadata nodes
|
|
567
|
+
feed_metadata = self._build_feed_metadata()
|
|
568
|
+
|
|
569
|
+
# Circuits
|
|
570
|
+
circuits: dict[str, SpanCircuitSnapshot] = {}
|
|
571
|
+
for node_id in self._acc.all_node_types():
|
|
572
|
+
if self._is_circuit_node(node_id):
|
|
573
|
+
meta = feed_metadata.get(node_id, {})
|
|
574
|
+
device_type = meta.get("device_type", "circuit")
|
|
575
|
+
relative_position = meta.get("relative_position", "")
|
|
576
|
+
circuit = self._build_circuit(node_id, device_type, relative_position)
|
|
577
|
+
circuits[circuit.circuit_id] = circuit
|
|
578
|
+
|
|
579
|
+
# Synthesize unmapped tab entries
|
|
580
|
+
unmapped = self._build_unmapped_tabs(circuits)
|
|
581
|
+
circuits.update(unmapped)
|
|
582
|
+
|
|
583
|
+
# Battery, PV, and EVSE metadata
|
|
584
|
+
battery = self._build_battery()
|
|
585
|
+
pv = self._build_pv()
|
|
586
|
+
evse = self._build_evse_devices()
|
|
587
|
+
|
|
588
|
+
# BESS grid state for v2-native field
|
|
589
|
+
bess_node = self._acc.find_node_by_type(TYPE_BESS)
|
|
590
|
+
grid_state: str | None = None
|
|
591
|
+
if bess_node is not None:
|
|
592
|
+
gs = self._acc.get_prop(bess_node, "grid-state")
|
|
593
|
+
grid_state = gs if gs else None
|
|
594
|
+
|
|
595
|
+
# Derived state values
|
|
596
|
+
dsm_state = self._derive_dsm_state(core_node, grid_power, power_flow_grid)
|
|
597
|
+
current_run_config = self._derive_run_config(dsm_state, grid_islandable, dominant_power_source)
|
|
598
|
+
|
|
599
|
+
# Connection uptime since $state==ready
|
|
600
|
+
uptime = int(time.monotonic() - self._acc.ready_since) if self._acc.ready_since > 0.0 else 0
|
|
601
|
+
|
|
602
|
+
return SpanPanelSnapshot(
|
|
603
|
+
serial_number=self._acc.serial_number,
|
|
604
|
+
firmware_version=firmware,
|
|
605
|
+
main_relay_state=main_relay,
|
|
606
|
+
instant_grid_power_w=grid_power,
|
|
607
|
+
feedthrough_power_w=feedthrough_power,
|
|
608
|
+
main_meter_energy_consumed_wh=main_consumed,
|
|
609
|
+
main_meter_energy_produced_wh=main_produced,
|
|
610
|
+
feedthrough_energy_consumed_wh=feedthrough_consumed,
|
|
611
|
+
feedthrough_energy_produced_wh=feedthrough_produced,
|
|
612
|
+
dsm_state=dsm_state,
|
|
613
|
+
current_run_config=current_run_config,
|
|
614
|
+
door_state=door_state,
|
|
615
|
+
proximity_proven=self._acc.is_ready(),
|
|
616
|
+
uptime_s=uptime,
|
|
617
|
+
eth0_link=eth0,
|
|
618
|
+
wlan_link=wlan,
|
|
619
|
+
wwan_link=wwan_connected,
|
|
620
|
+
panel_size=self._panel_size,
|
|
621
|
+
dominant_power_source=dominant_power_source,
|
|
622
|
+
grid_state=grid_state,
|
|
623
|
+
grid_islandable=grid_islandable,
|
|
624
|
+
l1_voltage=l1_voltage,
|
|
625
|
+
l2_voltage=l2_voltage,
|
|
626
|
+
main_breaker_rating_a=main_breaker,
|
|
627
|
+
wifi_ssid=wifi_ssid,
|
|
628
|
+
vendor_cloud=vendor_cloud,
|
|
629
|
+
power_flow_pv=power_flow_pv,
|
|
630
|
+
power_flow_battery=power_flow_battery,
|
|
631
|
+
power_flow_grid=power_flow_grid,
|
|
632
|
+
power_flow_site=power_flow_site,
|
|
633
|
+
upstream_l1_current_a=upstream_l1_current,
|
|
634
|
+
upstream_l2_current_a=upstream_l2_current,
|
|
635
|
+
downstream_l1_current_a=downstream_l1_current,
|
|
636
|
+
downstream_l2_current_a=downstream_l2_current,
|
|
637
|
+
circuits=circuits,
|
|
638
|
+
battery=battery,
|
|
639
|
+
pv=pv,
|
|
640
|
+
evse=evse,
|
|
641
|
+
)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Build transport-agnostic field metadata from the Homie schema.
|
|
2
|
+
|
|
3
|
+
Maps every Homie property that ``HomieDeviceConsumer._build_snapshot()``
|
|
4
|
+
reads to a snapshot field path, then looks up the schema-declared unit
|
|
5
|
+
and datatype for each. The result is a dict the integration can consume
|
|
6
|
+
without any Homie knowledge.
|
|
7
|
+
|
|
8
|
+
Field path convention: ``{snapshot_type}.{field_name}``
|
|
9
|
+
- ``panel`` — SpanPanelSnapshot
|
|
10
|
+
- ``circuit`` — SpanCircuitSnapshot
|
|
11
|
+
- ``battery`` — SpanBatterySnapshot
|
|
12
|
+
- ``pv`` — SpanPVSnapshot
|
|
13
|
+
- ``evse`` — SpanEvseSnapshot
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from span_panel_api.models import FieldMetadata, HomieSchemaTypes
|
|
19
|
+
from span_panel_api_schema_0.const import (
|
|
20
|
+
TYPE_BESS,
|
|
21
|
+
TYPE_CIRCUIT,
|
|
22
|
+
TYPE_CORE,
|
|
23
|
+
TYPE_EVSE,
|
|
24
|
+
TYPE_LUGS,
|
|
25
|
+
TYPE_LUGS_DOWNSTREAM,
|
|
26
|
+
TYPE_LUGS_UPSTREAM,
|
|
27
|
+
TYPE_POWER_FLOWS,
|
|
28
|
+
TYPE_PV,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Static mapping: (node_type, property_id) → snapshot field path
|
|
33
|
+
#
|
|
34
|
+
# This encodes the library's internal knowledge of how _build_snapshot()
|
|
35
|
+
# maps Homie properties to snapshot dataclass fields. The mapping must be
|
|
36
|
+
# kept in sync with consumer.py (which held this class as homie.py before
|
|
37
|
+
# the Phase 0 relocation).
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
_PROPERTY_FIELD_MAP: tuple[tuple[str, str, str], ...] = (
|
|
41
|
+
# --- Core node → panel.* -------------------------------------------------
|
|
42
|
+
(TYPE_CORE, "software-version", "panel.firmware_version"),
|
|
43
|
+
(TYPE_CORE, "door", "panel.door_state"),
|
|
44
|
+
(TYPE_CORE, "relay", "panel.main_relay_state"),
|
|
45
|
+
(TYPE_CORE, "ethernet", "panel.eth0_link"),
|
|
46
|
+
(TYPE_CORE, "wifi", "panel.wlan_link"),
|
|
47
|
+
(TYPE_CORE, "vendor-cloud", "panel.vendor_cloud"),
|
|
48
|
+
(TYPE_CORE, "dominant-power-source", "panel.dominant_power_source"),
|
|
49
|
+
(TYPE_CORE, "grid-islandable", "panel.grid_islandable"),
|
|
50
|
+
(TYPE_CORE, "l1-voltage", "panel.l1_voltage"),
|
|
51
|
+
(TYPE_CORE, "l2-voltage", "panel.l2_voltage"),
|
|
52
|
+
(TYPE_CORE, "breaker-rating", "panel.main_breaker_rating_a"),
|
|
53
|
+
(TYPE_CORE, "wifi-ssid", "panel.wifi_ssid"),
|
|
54
|
+
# --- Upstream lugs → panel.* (main meter) --------------------------------
|
|
55
|
+
(TYPE_LUGS_UPSTREAM, "active-power", "panel.instant_grid_power_w"),
|
|
56
|
+
(TYPE_LUGS_UPSTREAM, "imported-energy", "panel.main_meter_energy_consumed_wh"),
|
|
57
|
+
(TYPE_LUGS_UPSTREAM, "exported-energy", "panel.main_meter_energy_produced_wh"),
|
|
58
|
+
(TYPE_LUGS_UPSTREAM, "l1-current", "panel.upstream_l1_current_a"),
|
|
59
|
+
(TYPE_LUGS_UPSTREAM, "l2-current", "panel.upstream_l2_current_a"),
|
|
60
|
+
# --- Downstream lugs → panel.* (feedthrough) -----------------------------
|
|
61
|
+
(TYPE_LUGS_DOWNSTREAM, "active-power", "panel.feedthrough_power_w"),
|
|
62
|
+
(TYPE_LUGS_DOWNSTREAM, "imported-energy", "panel.feedthrough_energy_consumed_wh"),
|
|
63
|
+
(TYPE_LUGS_DOWNSTREAM, "exported-energy", "panel.feedthrough_energy_produced_wh"),
|
|
64
|
+
(TYPE_LUGS_DOWNSTREAM, "l1-current", "panel.downstream_l1_current_a"),
|
|
65
|
+
(TYPE_LUGS_DOWNSTREAM, "l2-current", "panel.downstream_l2_current_a"),
|
|
66
|
+
# --- Circuit → circuit.* -------------------------------------------------
|
|
67
|
+
(TYPE_CIRCUIT, "active-power", "circuit.instant_power_w"),
|
|
68
|
+
(TYPE_CIRCUIT, "exported-energy", "circuit.consumed_energy_wh"),
|
|
69
|
+
(TYPE_CIRCUIT, "imported-energy", "circuit.produced_energy_wh"),
|
|
70
|
+
(TYPE_CIRCUIT, "name", "circuit.name"),
|
|
71
|
+
(TYPE_CIRCUIT, "relay", "circuit.relay_state"),
|
|
72
|
+
(TYPE_CIRCUIT, "shed-priority", "circuit.priority"),
|
|
73
|
+
(TYPE_CIRCUIT, "current", "circuit.current_a"),
|
|
74
|
+
(TYPE_CIRCUIT, "breaker-rating", "circuit.breaker_rating_a"),
|
|
75
|
+
(TYPE_CIRCUIT, "space", "circuit.tabs"),
|
|
76
|
+
(TYPE_CIRCUIT, "sheddable", "circuit.is_sheddable"),
|
|
77
|
+
(TYPE_CIRCUIT, "never-backup", "circuit.is_never_backup"),
|
|
78
|
+
(TYPE_CIRCUIT, "always-on", "circuit.always_on"),
|
|
79
|
+
(TYPE_CIRCUIT, "dipole", "circuit.is_240v"),
|
|
80
|
+
(TYPE_CIRCUIT, "relay-requester", "circuit.relay_requester"),
|
|
81
|
+
# --- BESS → battery.* ----------------------------------------------------
|
|
82
|
+
(TYPE_BESS, "soc", "battery.soe_percentage"),
|
|
83
|
+
(TYPE_BESS, "soe", "battery.soe_kwh"),
|
|
84
|
+
(TYPE_BESS, "vendor-name", "battery.vendor_name"),
|
|
85
|
+
(TYPE_BESS, "product-name", "battery.product_name"),
|
|
86
|
+
(TYPE_BESS, "model", "battery.model"),
|
|
87
|
+
(TYPE_BESS, "serial-number", "battery.serial_number"),
|
|
88
|
+
(TYPE_BESS, "software-version", "battery.software_version"),
|
|
89
|
+
(TYPE_BESS, "nameplate-capacity", "battery.nameplate_capacity_kwh"),
|
|
90
|
+
(TYPE_BESS, "connected", "battery.connected"),
|
|
91
|
+
(TYPE_BESS, "grid-state", "panel.grid_state"),
|
|
92
|
+
# --- PV → pv.* -----------------------------------------------------------
|
|
93
|
+
(TYPE_PV, "vendor-name", "pv.vendor_name"),
|
|
94
|
+
(TYPE_PV, "product-name", "pv.product_name"),
|
|
95
|
+
(TYPE_PV, "nameplate-capacity", "pv.nameplate_capacity_w"),
|
|
96
|
+
(TYPE_PV, "feed", "pv.feed_circuit_id"),
|
|
97
|
+
(TYPE_PV, "relative-position", "pv.relative_position"), # IN_PANEL | UPSTREAM | DOWNSTREAM
|
|
98
|
+
# --- EVSE → evse.* -------------------------------------------------------
|
|
99
|
+
(TYPE_EVSE, "status", "evse.status"),
|
|
100
|
+
(TYPE_EVSE, "lock-state", "evse.lock_state"),
|
|
101
|
+
(TYPE_EVSE, "advertised-current", "evse.advertised_current_a"),
|
|
102
|
+
(TYPE_EVSE, "vendor-name", "evse.vendor_name"),
|
|
103
|
+
(TYPE_EVSE, "product-name", "evse.product_name"),
|
|
104
|
+
(TYPE_EVSE, "part-number", "evse.part_number"),
|
|
105
|
+
(TYPE_EVSE, "serial-number", "evse.serial_number"),
|
|
106
|
+
(TYPE_EVSE, "software-version", "evse.software_version"),
|
|
107
|
+
(TYPE_EVSE, "feed", "evse.feed_circuit_id"),
|
|
108
|
+
# --- Power flows → panel.* -----------------------------------------------
|
|
109
|
+
(TYPE_POWER_FLOWS, "pv", "panel.power_flow_pv"),
|
|
110
|
+
(TYPE_POWER_FLOWS, "battery", "panel.power_flow_battery"),
|
|
111
|
+
(TYPE_POWER_FLOWS, "grid", "panel.power_flow_grid"),
|
|
112
|
+
(TYPE_POWER_FLOWS, "site", "panel.power_flow_site"),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Generic lugs type used by some firmware versions instead of typed variants.
|
|
116
|
+
# Properties are identical; only the node type differs.
|
|
117
|
+
_LUGS_FALLBACK: dict[str, str] = {
|
|
118
|
+
TYPE_LUGS_UPSTREAM: TYPE_LUGS,
|
|
119
|
+
TYPE_LUGS_DOWNSTREAM: TYPE_LUGS,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _lookup_property(
|
|
124
|
+
schema_types: HomieSchemaTypes,
|
|
125
|
+
node_type: str,
|
|
126
|
+
property_id: str,
|
|
127
|
+
) -> dict[str, object] | None:
|
|
128
|
+
"""Look up a property definition in the schema, with lugs fallback."""
|
|
129
|
+
node_props = schema_types.get(node_type)
|
|
130
|
+
if isinstance(node_props, dict):
|
|
131
|
+
prop_def = node_props.get(property_id)
|
|
132
|
+
if isinstance(prop_def, dict):
|
|
133
|
+
return prop_def
|
|
134
|
+
|
|
135
|
+
# Try generic lugs fallback
|
|
136
|
+
fallback_type = _LUGS_FALLBACK.get(node_type)
|
|
137
|
+
if fallback_type is not None:
|
|
138
|
+
fb_props = schema_types.get(fallback_type)
|
|
139
|
+
if isinstance(fb_props, dict):
|
|
140
|
+
prop_def = fb_props.get(property_id)
|
|
141
|
+
if isinstance(prop_def, dict):
|
|
142
|
+
return prop_def
|
|
143
|
+
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def build_field_metadata(
|
|
148
|
+
schema_types: HomieSchemaTypes,
|
|
149
|
+
) -> dict[str, FieldMetadata]:
|
|
150
|
+
"""Build field metadata from the Homie schema.
|
|
151
|
+
|
|
152
|
+
Iterates the static property-to-field mapping, looks up each property
|
|
153
|
+
in the schema to get its declared unit and datatype, and returns a dict
|
|
154
|
+
keyed by snapshot field path.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
schema_types: The ``V2HomieSchema.types`` dict.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Dict mapping field paths to ``FieldMetadata``.
|
|
161
|
+
"""
|
|
162
|
+
result: dict[str, FieldMetadata] = {}
|
|
163
|
+
|
|
164
|
+
for node_type, property_id, field_path in _PROPERTY_FIELD_MAP:
|
|
165
|
+
prop_def = _lookup_property(schema_types, node_type, property_id)
|
|
166
|
+
if prop_def is None:
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
raw_unit = prop_def.get("unit")
|
|
170
|
+
unit = str(raw_unit) if raw_unit is not None else None
|
|
171
|
+
raw_datatype = prop_def.get("datatype")
|
|
172
|
+
datatype = str(raw_datatype) if raw_datatype is not None else "string"
|
|
173
|
+
|
|
174
|
+
result[field_path] = FieldMetadata(unit=unit, datatype=datatype)
|
|
175
|
+
|
|
176
|
+
return result
|
|
File without changes
|
|
@@ -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,11 @@
|
|
|
1
|
+
span_panel_api_schema_0/__init__.py,sha256=FRN8R2YS8lUXXm2td4thxqb8OSTh-0_DNCw6XrRyZG0,589
|
|
2
|
+
span_panel_api_schema_0/accumulator.py,sha256=KGkX68hfTMrN0Pv7m_wYEwb7CsxO3vAbG8drJkMk4ao,10424
|
|
3
|
+
span_panel_api_schema_0/adapter.py,sha256=s8iydZsi_lbvrZtYq28nZnGDSUBNf5Oqpd3XnP1qumw,2886
|
|
4
|
+
span_panel_api_schema_0/const.py,sha256=2zswfoyGYNnZy8vQcZ4XB_YRMc9bJlIQImVoiNYcdms,4030
|
|
5
|
+
span_panel_api_schema_0/consumer.py,sha256=lcAxLNJorLVfVOSnR4fCi202RbGkf_tvrkXF7PamPbM,27369
|
|
6
|
+
span_panel_api_schema_0/field_metadata.py,sha256=M4d3qbx9xYXwUUXPbQ61crJTuVAyB5vCPXsXLtCxeGI,7893
|
|
7
|
+
span_panel_api_schema_0/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
span_panel_api_schema_0-1.0.0b1.dist-info/METADATA,sha256=hwMd9CuWqeGVHv1VrW11anSqPNNFmg-AngsogsvdQig,2264
|
|
9
|
+
span_panel_api_schema_0-1.0.0b1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
10
|
+
span_panel_api_schema_0-1.0.0b1.dist-info/entry_points.txt,sha256=cj7Udv-ohuTOC5dDFqBE17a3L6pF9Tfn_lYQ_MHHkj8,86
|
|
11
|
+
span_panel_api_schema_0-1.0.0b1.dist-info/RECORD,,
|