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.
@@ -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
+ )