span-panel-api-schema-0 1.0.0__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 +115 -0
- span_panel_api_schema_0/const.py +83 -0
- span_panel_api_schema_0/consumer.py +647 -0
- span_panel_api_schema_0/field_metadata.py +205 -0
- span_panel_api_schema_0/py.typed +0 -0
- span_panel_api_schema_0-1.0.0.dist-info/METADATA +45 -0
- span_panel_api_schema_0-1.0.0.dist-info/RECORD +11 -0
- span_panel_api_schema_0-1.0.0.dist-info/WHEEL +4 -0
- span_panel_api_schema_0-1.0.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,647 @@
|
|
|
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
|
+
# Flat is the irregular side: it puts the SKU in `model` on the BESS and in
|
|
326
|
+
# `part-number` on the EVSE, for the same concept. The snapshot speaks v1.0's
|
|
327
|
+
# vocabulary now, so translate rather than mirror -- `product-name` is the
|
|
328
|
+
# designation and flat's `bess/model` is the SKU.
|
|
329
|
+
model=pn if pn else None,
|
|
330
|
+
part_number=mdl if mdl else None,
|
|
331
|
+
serial_number=sn if sn else None,
|
|
332
|
+
software_version=sw if sw else None,
|
|
333
|
+
nameplate_capacity_kwh=_parse_float(nc) if nc else None,
|
|
334
|
+
connected=conn.lower() == "true" if conn else None,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
def _build_pv(self) -> SpanPVSnapshot:
|
|
338
|
+
"""Build PV snapshot from the first PV metadata node."""
|
|
339
|
+
pv_node = self._acc.find_node_by_type(TYPE_PV)
|
|
340
|
+
if pv_node is None:
|
|
341
|
+
return SpanPVSnapshot()
|
|
342
|
+
|
|
343
|
+
vn = self._acc.get_prop(pv_node, "vendor-name")
|
|
344
|
+
pn = self._acc.get_prop(pv_node, "product-name")
|
|
345
|
+
sw = self._acc.get_prop(pv_node, "software-version")
|
|
346
|
+
nc = self._acc.get_prop(pv_node, "nameplate-capacity")
|
|
347
|
+
feed = self._acc.get_prop(pv_node, "feed")
|
|
348
|
+
rel_pos = self._acc.get_prop(pv_node, "relative-position")
|
|
349
|
+
|
|
350
|
+
return SpanPVSnapshot(
|
|
351
|
+
vendor_name=vn if vn else None,
|
|
352
|
+
model=pn if pn else None,
|
|
353
|
+
software_version=sw if sw else None,
|
|
354
|
+
nameplate_capacity_w=_parse_float(nc) if nc else None,
|
|
355
|
+
feed_circuit_id=normalize_circuit_id(feed) if feed else None,
|
|
356
|
+
relative_position=rel_pos.upper() if rel_pos else None,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def _build_evse_devices(self) -> dict[str, SpanEvseSnapshot]:
|
|
360
|
+
"""Build EVSE snapshots from all EVSE metadata nodes."""
|
|
361
|
+
result: dict[str, SpanEvseSnapshot] = {}
|
|
362
|
+
for node_id, node_type in self._acc.all_node_types().items():
|
|
363
|
+
if node_type != TYPE_EVSE:
|
|
364
|
+
continue
|
|
365
|
+
feed = self._acc.get_prop(node_id, "feed")
|
|
366
|
+
if not feed:
|
|
367
|
+
continue
|
|
368
|
+
adv = self._acc.get_prop(node_id, "advertised-current")
|
|
369
|
+
result[node_id] = SpanEvseSnapshot(
|
|
370
|
+
node_id=node_id,
|
|
371
|
+
feed_circuit_id=normalize_circuit_id(feed),
|
|
372
|
+
status=self._acc.get_prop(node_id, "status") or "UNKNOWN",
|
|
373
|
+
lock_state=self._acc.get_prop(node_id, "lock-state") or "UNKNOWN",
|
|
374
|
+
advertised_current_a=_parse_float(adv) if adv else None,
|
|
375
|
+
vendor_name=self._acc.get_prop(node_id, "vendor-name") or None,
|
|
376
|
+
model=self._acc.get_prop(node_id, "product-name") or None,
|
|
377
|
+
part_number=self._acc.get_prop(node_id, "part-number") or None,
|
|
378
|
+
serial_number=self._acc.get_prop(node_id, "serial-number") or None,
|
|
379
|
+
software_version=self._acc.get_prop(node_id, "software-version") or None,
|
|
380
|
+
)
|
|
381
|
+
return result
|
|
382
|
+
|
|
383
|
+
def _derive_dsm_state(self, core_node: str | None, grid_power: float, power_flow_grid: float | None) -> str:
|
|
384
|
+
"""Derive dsm_state from multiple signals.
|
|
385
|
+
|
|
386
|
+
Priority:
|
|
387
|
+
1. bess/grid-state — authoritative when BESS is commissioned
|
|
388
|
+
2. dominant-power-source == GRID — grid is the primary source
|
|
389
|
+
3. grid_power or power_flow_grid non-zero — grid exchanging power
|
|
390
|
+
4. both grid signals zero AND DPS != GRID — islanded
|
|
391
|
+
"""
|
|
392
|
+
# 1. BESS grid-state is authoritative when available
|
|
393
|
+
bess_node = self._acc.find_node_by_type(TYPE_BESS)
|
|
394
|
+
if bess_node is not None:
|
|
395
|
+
gs = self._acc.get_prop(bess_node, "grid-state")
|
|
396
|
+
if gs == "ON_GRID":
|
|
397
|
+
return "DSM_ON_GRID"
|
|
398
|
+
if gs == "OFF_GRID":
|
|
399
|
+
return "DSM_OFF_GRID"
|
|
400
|
+
|
|
401
|
+
# 2-4. Fallback heuristic using DPS and grid power signals
|
|
402
|
+
if core_node is not None:
|
|
403
|
+
dps = self._acc.get_prop(core_node, "dominant-power-source")
|
|
404
|
+
if dps == "GRID":
|
|
405
|
+
return "DSM_ON_GRID"
|
|
406
|
+
|
|
407
|
+
if dps in ("BATTERY", "PV", "GENERATOR"):
|
|
408
|
+
grid_exchanging = abs(grid_power) > _GRID_POWER_EPSILON_W or (
|
|
409
|
+
power_flow_grid is not None and abs(power_flow_grid) > _GRID_POWER_EPSILON_W
|
|
410
|
+
)
|
|
411
|
+
return "DSM_ON_GRID" if grid_exchanging else "DSM_OFF_GRID"
|
|
412
|
+
|
|
413
|
+
return "UNKNOWN"
|
|
414
|
+
|
|
415
|
+
def _derive_run_config(self, dsm_state: str, grid_islandable: bool | None, dps: str | None) -> str:
|
|
416
|
+
"""Derive current_run_config from grid state, islandability, and power source.
|
|
417
|
+
|
|
418
|
+
Decision table:
|
|
419
|
+
- DSM_ON_GRID → PANEL_ON_GRID (regardless of islandable)
|
|
420
|
+
- DSM_OFF_GRID + islandable + BATTERY → PANEL_BACKUP
|
|
421
|
+
- DSM_OFF_GRID + islandable + PV/GENERATOR → PANEL_OFF_GRID
|
|
422
|
+
- DSM_OFF_GRID + islandable + other → UNKNOWN
|
|
423
|
+
- DSM_OFF_GRID + not islandable → UNKNOWN (shouldn't happen)
|
|
424
|
+
"""
|
|
425
|
+
if dsm_state == "DSM_ON_GRID":
|
|
426
|
+
return "PANEL_ON_GRID"
|
|
427
|
+
|
|
428
|
+
if dsm_state == "DSM_OFF_GRID":
|
|
429
|
+
if not grid_islandable:
|
|
430
|
+
return "UNKNOWN"
|
|
431
|
+
if dps == "BATTERY":
|
|
432
|
+
return "PANEL_BACKUP"
|
|
433
|
+
if dps in ("PV", "GENERATOR"):
|
|
434
|
+
return "PANEL_OFF_GRID"
|
|
435
|
+
return "UNKNOWN"
|
|
436
|
+
|
|
437
|
+
return "UNKNOWN"
|
|
438
|
+
|
|
439
|
+
def _build_unmapped_tabs(
|
|
440
|
+
self,
|
|
441
|
+
circuits: dict[str, SpanCircuitSnapshot],
|
|
442
|
+
) -> dict[str, SpanCircuitSnapshot]:
|
|
443
|
+
"""Synthesize unmapped tab entries for breaker positions with no circuit.
|
|
444
|
+
|
|
445
|
+
Creates zero-power SpanCircuitSnapshot entries for unoccupied positions
|
|
446
|
+
up to ``self._panel_size``.
|
|
447
|
+
"""
|
|
448
|
+
occupied_tabs: set[int] = set()
|
|
449
|
+
for circuit in circuits.values():
|
|
450
|
+
occupied_tabs.update(circuit.tabs)
|
|
451
|
+
|
|
452
|
+
unmapped: dict[str, SpanCircuitSnapshot] = {}
|
|
453
|
+
for tab in range(1, self._panel_size + 1):
|
|
454
|
+
if tab not in occupied_tabs:
|
|
455
|
+
circuit_id = f"unmapped_tab_{tab}"
|
|
456
|
+
unmapped[circuit_id] = SpanCircuitSnapshot(
|
|
457
|
+
circuit_id=circuit_id,
|
|
458
|
+
name=f"Unmapped Tab {tab}",
|
|
459
|
+
relay_state="CLOSED",
|
|
460
|
+
instant_power_w=0.0,
|
|
461
|
+
produced_energy_wh=0.0,
|
|
462
|
+
consumed_energy_wh=0.0,
|
|
463
|
+
tabs=[tab],
|
|
464
|
+
priority="UNKNOWN",
|
|
465
|
+
is_user_controllable=False,
|
|
466
|
+
is_sheddable=False,
|
|
467
|
+
is_never_backup=False,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
return unmapped
|
|
471
|
+
|
|
472
|
+
def _build_snapshot(self) -> SpanPanelSnapshot:
|
|
473
|
+
"""Build full snapshot from accumulated property values."""
|
|
474
|
+
core_node = self._acc.find_node_by_type(TYPE_CORE)
|
|
475
|
+
upstream_lugs = self._find_lugs_node(LUGS_UPSTREAM)
|
|
476
|
+
downstream_lugs = self._find_lugs_node(LUGS_DOWNSTREAM)
|
|
477
|
+
|
|
478
|
+
# Core properties
|
|
479
|
+
firmware = ""
|
|
480
|
+
door_state = "UNKNOWN"
|
|
481
|
+
main_relay = "UNKNOWN"
|
|
482
|
+
eth0 = False
|
|
483
|
+
wlan = False
|
|
484
|
+
wwan_connected = False
|
|
485
|
+
dominant_power_source: str | None = None
|
|
486
|
+
grid_islandable: bool | None = None
|
|
487
|
+
l1_voltage: float | None = None
|
|
488
|
+
l2_voltage: float | None = None
|
|
489
|
+
main_breaker: int | None = None
|
|
490
|
+
wifi_ssid: str | None = None
|
|
491
|
+
vendor_cloud: str | None = None
|
|
492
|
+
|
|
493
|
+
if core_node is not None:
|
|
494
|
+
firmware = self._acc.get_prop(core_node, "software-version")
|
|
495
|
+
door_state = self._acc.get_prop(core_node, "door", "UNKNOWN")
|
|
496
|
+
main_relay = self._acc.get_prop(core_node, "relay", "UNKNOWN")
|
|
497
|
+
eth0 = _parse_bool(self._acc.get_prop(core_node, "ethernet"))
|
|
498
|
+
wlan = _parse_bool(self._acc.get_prop(core_node, "wifi"))
|
|
499
|
+
|
|
500
|
+
vc = self._acc.get_prop(core_node, "vendor-cloud")
|
|
501
|
+
wwan_connected = vc == "CONNECTED"
|
|
502
|
+
vendor_cloud = vc if vc else None
|
|
503
|
+
|
|
504
|
+
dps = self._acc.get_prop(core_node, "dominant-power-source")
|
|
505
|
+
dominant_power_source = dps if dps else None
|
|
506
|
+
|
|
507
|
+
gi = self._acc.get_prop(core_node, "grid-islandable")
|
|
508
|
+
grid_islandable = _parse_bool(gi) if gi else None
|
|
509
|
+
|
|
510
|
+
l1v = self._acc.get_prop(core_node, "l1-voltage")
|
|
511
|
+
l1_voltage = _parse_float(l1v) if l1v else None
|
|
512
|
+
|
|
513
|
+
l2v = self._acc.get_prop(core_node, "l2-voltage")
|
|
514
|
+
l2_voltage = _parse_float(l2v) if l2v else None
|
|
515
|
+
|
|
516
|
+
br = self._acc.get_prop(core_node, "breaker-rating")
|
|
517
|
+
main_breaker = _parse_int(br) if br else None
|
|
518
|
+
|
|
519
|
+
ws = self._acc.get_prop(core_node, "wifi-ssid")
|
|
520
|
+
wifi_ssid = ws if ws else None
|
|
521
|
+
|
|
522
|
+
# Upstream lugs → main meter (grid connection)
|
|
523
|
+
# imported-energy = energy imported from the grid = consumed by the house
|
|
524
|
+
# exported-energy = energy exported to the grid = produced (solar)
|
|
525
|
+
grid_power = 0.0
|
|
526
|
+
main_consumed = 0.0
|
|
527
|
+
main_produced = 0.0
|
|
528
|
+
upstream_l1_current: float | None = None
|
|
529
|
+
upstream_l2_current: float | None = None
|
|
530
|
+
if upstream_lugs is not None:
|
|
531
|
+
grid_power = _parse_float(self._acc.get_prop(upstream_lugs, "active-power"))
|
|
532
|
+
main_consumed = _parse_float(self._acc.get_prop(upstream_lugs, "imported-energy"))
|
|
533
|
+
main_produced = _parse_float(self._acc.get_prop(upstream_lugs, "exported-energy"))
|
|
534
|
+
|
|
535
|
+
l1_i = self._acc.get_prop(upstream_lugs, "l1-current")
|
|
536
|
+
upstream_l1_current = _parse_float(l1_i) if l1_i else None
|
|
537
|
+
l2_i = self._acc.get_prop(upstream_lugs, "l2-current")
|
|
538
|
+
upstream_l2_current = _parse_float(l2_i) if l2_i else None
|
|
539
|
+
|
|
540
|
+
# Downstream lugs → feedthrough
|
|
541
|
+
feedthrough_power = 0.0
|
|
542
|
+
feedthrough_consumed = 0.0
|
|
543
|
+
feedthrough_produced = 0.0
|
|
544
|
+
downstream_l1_current: float | None = None
|
|
545
|
+
downstream_l2_current: float | None = None
|
|
546
|
+
if downstream_lugs is not None:
|
|
547
|
+
feedthrough_power = _parse_float(self._acc.get_prop(downstream_lugs, "active-power"))
|
|
548
|
+
feedthrough_consumed = _parse_float(self._acc.get_prop(downstream_lugs, "imported-energy"))
|
|
549
|
+
feedthrough_produced = _parse_float(self._acc.get_prop(downstream_lugs, "exported-energy"))
|
|
550
|
+
|
|
551
|
+
dl1_i = self._acc.get_prop(downstream_lugs, "l1-current")
|
|
552
|
+
downstream_l1_current = _parse_float(dl1_i) if dl1_i else None
|
|
553
|
+
dl2_i = self._acc.get_prop(downstream_lugs, "l2-current")
|
|
554
|
+
downstream_l2_current = _parse_float(dl2_i) if dl2_i else None
|
|
555
|
+
|
|
556
|
+
# Power flows
|
|
557
|
+
pf_node = self._acc.find_node_by_type(TYPE_POWER_FLOWS)
|
|
558
|
+
power_flow_pv: float | None = None
|
|
559
|
+
power_flow_battery: float | None = None
|
|
560
|
+
power_flow_grid: float | None = None
|
|
561
|
+
power_flow_site: float | None = None
|
|
562
|
+
if pf_node is not None:
|
|
563
|
+
pf_pv = self._acc.get_prop(pf_node, "pv")
|
|
564
|
+
power_flow_pv = _parse_float(pf_pv) if pf_pv else None
|
|
565
|
+
pf_bat = self._acc.get_prop(pf_node, "battery")
|
|
566
|
+
power_flow_battery = _parse_float(pf_bat) if pf_bat else None
|
|
567
|
+
pf_grid = self._acc.get_prop(pf_node, "grid")
|
|
568
|
+
power_flow_grid = _parse_float(pf_grid) if pf_grid else None
|
|
569
|
+
pf_site = self._acc.get_prop(pf_node, "site")
|
|
570
|
+
power_flow_site = _parse_float(pf_site) if pf_site else None
|
|
571
|
+
|
|
572
|
+
# Build metadata annotations from PV/EVSE metadata nodes
|
|
573
|
+
feed_metadata = self._build_feed_metadata()
|
|
574
|
+
|
|
575
|
+
# Circuits
|
|
576
|
+
circuits: dict[str, SpanCircuitSnapshot] = {}
|
|
577
|
+
for node_id in self._acc.all_node_types():
|
|
578
|
+
if self._is_circuit_node(node_id):
|
|
579
|
+
meta = feed_metadata.get(node_id, {})
|
|
580
|
+
device_type = meta.get("device_type", "circuit")
|
|
581
|
+
relative_position = meta.get("relative_position", "")
|
|
582
|
+
circuit = self._build_circuit(node_id, device_type, relative_position)
|
|
583
|
+
circuits[circuit.circuit_id] = circuit
|
|
584
|
+
|
|
585
|
+
# Synthesize unmapped tab entries
|
|
586
|
+
unmapped = self._build_unmapped_tabs(circuits)
|
|
587
|
+
circuits.update(unmapped)
|
|
588
|
+
|
|
589
|
+
# Battery, PV, and EVSE metadata
|
|
590
|
+
battery = self._build_battery()
|
|
591
|
+
pv = self._build_pv()
|
|
592
|
+
evse = self._build_evse_devices()
|
|
593
|
+
|
|
594
|
+
# BESS grid state for v2-native field
|
|
595
|
+
bess_node = self._acc.find_node_by_type(TYPE_BESS)
|
|
596
|
+
grid_state: str | None = None
|
|
597
|
+
if bess_node is not None:
|
|
598
|
+
gs = self._acc.get_prop(bess_node, "grid-state")
|
|
599
|
+
grid_state = gs if gs else None
|
|
600
|
+
|
|
601
|
+
# Derived state values
|
|
602
|
+
dsm_state = self._derive_dsm_state(core_node, grid_power, power_flow_grid)
|
|
603
|
+
current_run_config = self._derive_run_config(dsm_state, grid_islandable, dominant_power_source)
|
|
604
|
+
|
|
605
|
+
# Connection uptime since $state==ready
|
|
606
|
+
uptime = int(time.monotonic() - self._acc.ready_since) if self._acc.ready_since > 0.0 else 0
|
|
607
|
+
|
|
608
|
+
return SpanPanelSnapshot(
|
|
609
|
+
serial_number=self._acc.serial_number,
|
|
610
|
+
firmware_version=firmware,
|
|
611
|
+
main_relay_state=main_relay,
|
|
612
|
+
instant_grid_power_w=grid_power,
|
|
613
|
+
feedthrough_power_w=feedthrough_power,
|
|
614
|
+
main_meter_energy_consumed_wh=main_consumed,
|
|
615
|
+
main_meter_energy_produced_wh=main_produced,
|
|
616
|
+
feedthrough_energy_consumed_wh=feedthrough_consumed,
|
|
617
|
+
feedthrough_energy_produced_wh=feedthrough_produced,
|
|
618
|
+
dsm_state=dsm_state,
|
|
619
|
+
current_run_config=current_run_config,
|
|
620
|
+
door_state=door_state,
|
|
621
|
+
proximity_proven=self._acc.is_ready(),
|
|
622
|
+
uptime_s=uptime,
|
|
623
|
+
eth0_link=eth0,
|
|
624
|
+
wlan_link=wlan,
|
|
625
|
+
wwan_link=wwan_connected,
|
|
626
|
+
panel_size=self._panel_size,
|
|
627
|
+
dominant_power_source=dominant_power_source,
|
|
628
|
+
grid_state=grid_state,
|
|
629
|
+
grid_islandable=grid_islandable,
|
|
630
|
+
l1_voltage=l1_voltage,
|
|
631
|
+
l2_voltage=l2_voltage,
|
|
632
|
+
main_breaker_rating_a=main_breaker,
|
|
633
|
+
wifi_ssid=wifi_ssid,
|
|
634
|
+
vendor_cloud=vendor_cloud,
|
|
635
|
+
power_flow_pv=power_flow_pv,
|
|
636
|
+
power_flow_battery=power_flow_battery,
|
|
637
|
+
power_flow_grid=power_flow_grid,
|
|
638
|
+
power_flow_site=power_flow_site,
|
|
639
|
+
upstream_l1_current_a=upstream_l1_current,
|
|
640
|
+
upstream_l2_current_a=upstream_l2_current,
|
|
641
|
+
downstream_l1_current_a=downstream_l1_current,
|
|
642
|
+
downstream_l2_current_a=downstream_l2_current,
|
|
643
|
+
circuits=circuits,
|
|
644
|
+
battery=battery,
|
|
645
|
+
pv=pv,
|
|
646
|
+
evse=evse,
|
|
647
|
+
)
|