span-panel-api 2.2.2__tar.gz → 2.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/PKG-INFO +2 -1
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/README.md +1 -0
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/pyproject.toml +1 -1
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/__init__.py +2 -0
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/models.py +30 -1
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/mqtt/client.py +31 -15
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/mqtt/homie.py +10 -20
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/LICENSE +0 -0
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/auth.py +0 -0
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/const.py +0 -0
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/exceptions.py +0 -0
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/protocol.py +0 -0
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/simulation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.3
|
|
4
4
|
Summary: A client library for SPAN Panel API
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Author: SpanPanel
|
|
@@ -243,6 +243,7 @@ pem = await download_ca_cert("192.168.1.100")
|
|
|
243
243
|
|
|
244
244
|
# Fetch the Homie property schema (unauthenticated)
|
|
245
245
|
schema = await get_homie_schema("192.168.1.100")
|
|
246
|
+
print(f"Panel size: {schema.panel_size} spaces")
|
|
246
247
|
print(f"Schema hash: {schema.types_schema_hash}")
|
|
247
248
|
|
|
248
249
|
# Rotate MQTT broker password (invalidates previous password)
|
|
@@ -223,6 +223,7 @@ pem = await download_ca_cert("192.168.1.100")
|
|
|
223
223
|
|
|
224
224
|
# Fetch the Homie property schema (unauthenticated)
|
|
225
225
|
schema = await get_homie_schema("192.168.1.100")
|
|
226
|
+
print(f"Panel size: {schema.panel_size} spaces")
|
|
226
227
|
print(f"Schema hash: {schema.types_schema_hash}")
|
|
227
228
|
|
|
228
229
|
# Rotate MQTT broker password (invalidates previous password)
|
|
@@ -24,6 +24,7 @@ from .models import (
|
|
|
24
24
|
SpanPanelSnapshot,
|
|
25
25
|
SpanPVSnapshot,
|
|
26
26
|
V2AuthResponse,
|
|
27
|
+
V2HomieSchema,
|
|
27
28
|
V2StatusInfo,
|
|
28
29
|
)
|
|
29
30
|
from .mqtt import MqttClientConfig, SpanMqttClient
|
|
@@ -66,6 +67,7 @@ __all__ = [ # noqa: RUF022
|
|
|
66
67
|
"detect_api_version",
|
|
67
68
|
# v2 auth
|
|
68
69
|
"V2AuthResponse",
|
|
70
|
+
"V2HomieSchema",
|
|
69
71
|
"V2StatusInfo",
|
|
70
72
|
"download_ca_cert",
|
|
71
73
|
"get_homie_schema",
|
|
@@ -110,6 +110,9 @@ class V2StatusInfo:
|
|
|
110
110
|
firmware_version: str
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
_CIRCUIT_TYPE_KEY = "energy.ebus.device.circuit"
|
|
114
|
+
|
|
115
|
+
|
|
113
116
|
@dataclass(frozen=True, slots=True)
|
|
114
117
|
class V2HomieSchema:
|
|
115
118
|
"""Response from GET /api/v2/homie/schema."""
|
|
@@ -118,6 +121,32 @@ class V2HomieSchema:
|
|
|
118
121
|
types_schema_hash: str # SHA-256, first 16 hex chars
|
|
119
122
|
types: dict[str, dict[str, object]] # {type_name: {prop_name: {attr: value}}}
|
|
120
123
|
|
|
124
|
+
@property
|
|
125
|
+
def panel_size(self) -> int:
|
|
126
|
+
"""Extract panel size from the circuit ``space`` property format.
|
|
127
|
+
|
|
128
|
+
The Homie schema defines ``space`` with ``"format": "min:max:step"``
|
|
129
|
+
(e.g. ``"1:32:1"``). The *max* value is the number of breaker spaces
|
|
130
|
+
in the panel.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
ValueError: If the space format is missing or unparseable.
|
|
134
|
+
"""
|
|
135
|
+
circuit_type = self.types.get(_CIRCUIT_TYPE_KEY, {})
|
|
136
|
+
space_prop = circuit_type.get("space")
|
|
137
|
+
if not isinstance(space_prop, dict):
|
|
138
|
+
raise ValueError(f"Schema missing '{_CIRCUIT_TYPE_KEY}/space' property")
|
|
139
|
+
fmt = space_prop.get("format")
|
|
140
|
+
if not isinstance(fmt, str):
|
|
141
|
+
raise ValueError(f"Schema '{_CIRCUIT_TYPE_KEY}/space' has no format string")
|
|
142
|
+
parts = fmt.split(":")
|
|
143
|
+
if len(parts) != 3:
|
|
144
|
+
raise ValueError(f"Unexpected space format '{fmt}', expected 'min:max:step'")
|
|
145
|
+
try:
|
|
146
|
+
return int(parts[1])
|
|
147
|
+
except ValueError as exc:
|
|
148
|
+
raise ValueError(f"Cannot parse max from space format '{fmt}'") from exc
|
|
149
|
+
|
|
121
150
|
|
|
122
151
|
@dataclass(frozen=True, slots=True)
|
|
123
152
|
class SpanPanelSnapshot:
|
|
@@ -146,6 +175,7 @@ class SpanPanelSnapshot:
|
|
|
146
175
|
eth0_link: bool # v1: direct | v2: core/ethernet
|
|
147
176
|
wlan_link: bool # v1: direct | v2: core/wifi
|
|
148
177
|
wwan_link: bool # v1: direct | v2: vendor-cloud == "CONNECTED"
|
|
178
|
+
panel_size: int # Total breaker spaces (from Homie schema space format)
|
|
149
179
|
|
|
150
180
|
# v2-native fields — None for REST transport
|
|
151
181
|
dominant_power_source: str | None = None # v2: core/dominant-power-source (settable)
|
|
@@ -156,7 +186,6 @@ class SpanPanelSnapshot:
|
|
|
156
186
|
main_breaker_rating_a: int | None = None # v2: core/breaker-rating (A)
|
|
157
187
|
wifi_ssid: str | None = None # v2: core/wifi-ssid
|
|
158
188
|
vendor_cloud: str | None = None # v2: core/vendor-cloud
|
|
159
|
-
panel_size: int | None = None # v2: core/panel-size (total breaker spaces)
|
|
160
189
|
|
|
161
190
|
# Power flows (None when node not present)
|
|
162
191
|
power_flow_pv: float | None = None # v2: power-flows/pv (W)
|
|
@@ -11,6 +11,7 @@ import asyncio
|
|
|
11
11
|
from collections.abc import Awaitable, Callable
|
|
12
12
|
import logging
|
|
13
13
|
|
|
14
|
+
from ..auth import get_homie_schema
|
|
14
15
|
from ..exceptions import SpanPanelConnectionError, SpanPanelServerError
|
|
15
16
|
from ..models import SpanPanelSnapshot
|
|
16
17
|
from ..protocol import PanelCapability
|
|
@@ -43,7 +44,7 @@ class SpanMqttClient:
|
|
|
43
44
|
self._snapshot_interval = snapshot_interval
|
|
44
45
|
|
|
45
46
|
self._bridge: AsyncMqttBridge | None = None
|
|
46
|
-
self._homie =
|
|
47
|
+
self._homie: HomieDeviceConsumer | None = None
|
|
47
48
|
self._streaming = False
|
|
48
49
|
self._snapshot_callbacks: list[Callable[[SpanPanelSnapshot], Awaitable[None]]] = []
|
|
49
50
|
self._ready_event: asyncio.Event | None = None
|
|
@@ -51,6 +52,12 @@ class SpanMqttClient:
|
|
|
51
52
|
self._background_tasks: set[asyncio.Task[None]] = set()
|
|
52
53
|
self._snapshot_timer: asyncio.TimerHandle | None = None
|
|
53
54
|
|
|
55
|
+
def _require_homie(self) -> HomieDeviceConsumer:
|
|
56
|
+
"""Return the HomieDeviceConsumer, raising if not yet connected."""
|
|
57
|
+
if self._homie is None:
|
|
58
|
+
raise SpanPanelConnectionError("Client not connected — call connect() first")
|
|
59
|
+
return self._homie
|
|
60
|
+
|
|
54
61
|
# -- SpanPanelClientProtocol -------------------------------------------
|
|
55
62
|
|
|
56
63
|
@property
|
|
@@ -72,10 +79,11 @@ class SpanMqttClient:
|
|
|
72
79
|
"""Connect to MQTT broker and wait for Homie device ready.
|
|
73
80
|
|
|
74
81
|
Flow:
|
|
75
|
-
1.
|
|
76
|
-
2.
|
|
77
|
-
3.
|
|
78
|
-
4.
|
|
82
|
+
1. Fetch Homie schema to determine panel size
|
|
83
|
+
2. Create AsyncMqttBridge with broker credentials
|
|
84
|
+
3. Connect to MQTT broker
|
|
85
|
+
4. Subscribe to ebus/5/{serial}/#
|
|
86
|
+
5. Wait for $state==ready and $description parsed
|
|
79
87
|
|
|
80
88
|
Raises:
|
|
81
89
|
SpanPanelConnectionError: Cannot connect or device not ready
|
|
@@ -84,6 +92,10 @@ class SpanMqttClient:
|
|
|
84
92
|
self._loop = asyncio.get_running_loop()
|
|
85
93
|
self._ready_event = asyncio.Event()
|
|
86
94
|
|
|
95
|
+
# Fetch schema to determine panel size before processing any messages
|
|
96
|
+
schema = await get_homie_schema(self._host)
|
|
97
|
+
self._homie = HomieDeviceConsumer(self._serial_number, schema.panel_size)
|
|
98
|
+
|
|
87
99
|
_LOGGER.debug(
|
|
88
100
|
"MQTT: Creating bridge to %s:%s (serial=%s)",
|
|
89
101
|
self._broker_config.broker_host,
|
|
@@ -145,7 +157,7 @@ class SpanMqttClient:
|
|
|
145
157
|
|
|
146
158
|
async def ping(self) -> bool:
|
|
147
159
|
"""Check if MQTT connection is alive and device is ready."""
|
|
148
|
-
if self._bridge is None:
|
|
160
|
+
if self._bridge is None or self._homie is None:
|
|
149
161
|
return False
|
|
150
162
|
return self._bridge.is_connected() and self._homie.is_ready()
|
|
151
163
|
|
|
@@ -154,7 +166,7 @@ class SpanMqttClient:
|
|
|
154
166
|
|
|
155
167
|
No network call — snapshot is built from in-memory property values.
|
|
156
168
|
"""
|
|
157
|
-
return self.
|
|
169
|
+
return self._require_homie().build_snapshot()
|
|
158
170
|
|
|
159
171
|
# -- CircuitControlProtocol --------------------------------------------
|
|
160
172
|
|
|
@@ -188,7 +200,7 @@ class SpanMqttClient:
|
|
|
188
200
|
Args:
|
|
189
201
|
value: DPS enum value (GRID, BATTERY, NONE, GENERATOR, PV)
|
|
190
202
|
"""
|
|
191
|
-
core_node = self.
|
|
203
|
+
core_node = self._require_homie().find_node_by_type(TYPE_CORE)
|
|
192
204
|
if core_node is None:
|
|
193
205
|
raise SpanPanelServerError("Core node not found in panel topology")
|
|
194
206
|
topic = PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=core_node, prop="dominant-power-source")
|
|
@@ -228,15 +240,18 @@ class SpanMqttClient:
|
|
|
228
240
|
|
|
229
241
|
def _on_message(self, topic: str, payload: str) -> None:
|
|
230
242
|
"""Handle incoming MQTT message (called from asyncio loop)."""
|
|
231
|
-
|
|
232
|
-
|
|
243
|
+
homie = self._homie
|
|
244
|
+
if homie is None:
|
|
245
|
+
return
|
|
246
|
+
was_ready = homie.is_ready()
|
|
247
|
+
homie.handle_message(topic, payload)
|
|
233
248
|
|
|
234
249
|
# Check if device just became ready
|
|
235
|
-
if not was_ready and
|
|
250
|
+
if not was_ready and homie.is_ready() and self._ready_event is not None:
|
|
236
251
|
self._ready_event.set()
|
|
237
252
|
|
|
238
253
|
# Dispatch snapshot callbacks if streaming
|
|
239
|
-
if self._streaming and
|
|
254
|
+
if self._streaming and homie.is_ready() and self._loop is not None:
|
|
240
255
|
if self._snapshot_interval <= 0:
|
|
241
256
|
# No debounce — dispatch immediately (backward compat)
|
|
242
257
|
self._create_dispatch_task()
|
|
@@ -263,15 +278,16 @@ class SpanMqttClient:
|
|
|
263
278
|
returns as soon as all circuit names are populated, or when the
|
|
264
279
|
timeout elapses (non-fatal — entities will use fallback names).
|
|
265
280
|
"""
|
|
281
|
+
homie = self._require_homie()
|
|
266
282
|
deadline = asyncio.get_event_loop().time() + timeout
|
|
267
283
|
while asyncio.get_event_loop().time() < deadline:
|
|
268
|
-
missing =
|
|
284
|
+
missing = homie.circuit_nodes_missing_names()
|
|
269
285
|
if not missing:
|
|
270
286
|
_LOGGER.debug("All circuit names received")
|
|
271
287
|
return
|
|
272
288
|
await asyncio.sleep(_CIRCUIT_NAMES_POLL_INTERVAL_S)
|
|
273
289
|
|
|
274
|
-
still_missing =
|
|
290
|
+
still_missing = homie.circuit_nodes_missing_names()
|
|
275
291
|
if still_missing:
|
|
276
292
|
_LOGGER.warning(
|
|
277
293
|
"Timed out waiting for circuit names (%d still missing): %s",
|
|
@@ -313,7 +329,7 @@ class SpanMqttClient:
|
|
|
313
329
|
|
|
314
330
|
async def _dispatch_snapshot(self) -> None:
|
|
315
331
|
"""Build snapshot and send to all registered callbacks."""
|
|
316
|
-
snapshot = self.
|
|
332
|
+
snapshot = self._require_homie().build_snapshot()
|
|
317
333
|
for cb in list(self._snapshot_callbacks):
|
|
318
334
|
try:
|
|
319
335
|
await cb(snapshot)
|
|
@@ -65,8 +65,9 @@ class HomieDeviceConsumer:
|
|
|
65
65
|
(guaranteed by AsyncMqttBridge's call_soon_threadsafe dispatch).
|
|
66
66
|
"""
|
|
67
67
|
|
|
68
|
-
def __init__(self, serial_number: str) -> None:
|
|
68
|
+
def __init__(self, serial_number: str, panel_size: int) -> None:
|
|
69
69
|
self._serial_number = serial_number
|
|
70
|
+
self._panel_size = panel_size
|
|
70
71
|
self._topic_prefix = f"{TOPIC_PREFIX}/{serial_number}"
|
|
71
72
|
|
|
72
73
|
self._state: str = ""
|
|
@@ -440,28 +441,21 @@ class HomieDeviceConsumer:
|
|
|
440
441
|
|
|
441
442
|
return "UNKNOWN"
|
|
442
443
|
|
|
443
|
-
def _build_unmapped_tabs(
|
|
444
|
+
def _build_unmapped_tabs(
|
|
445
|
+
self,
|
|
446
|
+
circuits: dict[str, SpanCircuitSnapshot],
|
|
447
|
+
) -> dict[str, SpanCircuitSnapshot]:
|
|
444
448
|
"""Synthesize unmapped tab entries for breaker positions with no circuit.
|
|
445
449
|
|
|
446
|
-
|
|
447
|
-
|
|
450
|
+
Creates zero-power SpanCircuitSnapshot entries for unoccupied positions
|
|
451
|
+
up to ``self._panel_size``.
|
|
448
452
|
"""
|
|
449
|
-
# Collect all occupied tabs from commissioned circuits
|
|
450
453
|
occupied_tabs: set[int] = set()
|
|
451
454
|
for circuit in circuits.values():
|
|
452
455
|
occupied_tabs.update(circuit.tabs)
|
|
453
456
|
|
|
454
|
-
if not occupied_tabs:
|
|
455
|
-
return {}
|
|
456
|
-
|
|
457
|
-
# Panel size is the highest occupied tab (rounded up to even
|
|
458
|
-
# to cover both bus bar sides)
|
|
459
|
-
max_tab = max(occupied_tabs)
|
|
460
|
-
panel_size = max_tab if max_tab % 2 == 0 else max_tab + 1
|
|
461
|
-
|
|
462
|
-
# Synthesize entries for unoccupied positions
|
|
463
457
|
unmapped: dict[str, SpanCircuitSnapshot] = {}
|
|
464
|
-
for tab in range(1,
|
|
458
|
+
for tab in range(1, self._panel_size + 1):
|
|
465
459
|
if tab not in occupied_tabs:
|
|
466
460
|
circuit_id = f"unmapped_tab_{tab}"
|
|
467
461
|
unmapped[circuit_id] = SpanCircuitSnapshot(
|
|
@@ -500,7 +494,6 @@ class HomieDeviceConsumer:
|
|
|
500
494
|
main_breaker: int | None = None
|
|
501
495
|
wifi_ssid: str | None = None
|
|
502
496
|
vendor_cloud: str | None = None
|
|
503
|
-
panel_size: int | None = None
|
|
504
497
|
|
|
505
498
|
if core_node is not None:
|
|
506
499
|
firmware = self._get_prop(core_node, "software-version")
|
|
@@ -531,9 +524,6 @@ class HomieDeviceConsumer:
|
|
|
531
524
|
ws = self._get_prop(core_node, "wifi-ssid")
|
|
532
525
|
wifi_ssid = ws if ws else None
|
|
533
526
|
|
|
534
|
-
ps = self._get_prop(core_node, "panel-size")
|
|
535
|
-
panel_size = _parse_int(ps) if ps else None
|
|
536
|
-
|
|
537
527
|
# Upstream lugs → main meter (grid connection)
|
|
538
528
|
# imported-energy = energy imported from the grid = consumed by the house
|
|
539
529
|
# exported-energy = energy exported to the grid = produced (solar)
|
|
@@ -638,6 +628,7 @@ class HomieDeviceConsumer:
|
|
|
638
628
|
eth0_link=eth0,
|
|
639
629
|
wlan_link=wlan,
|
|
640
630
|
wwan_link=wwan_connected,
|
|
631
|
+
panel_size=self._panel_size,
|
|
641
632
|
dominant_power_source=dominant_power_source,
|
|
642
633
|
grid_state=grid_state,
|
|
643
634
|
grid_islandable=grid_islandable,
|
|
@@ -646,7 +637,6 @@ class HomieDeviceConsumer:
|
|
|
646
637
|
main_breaker_rating_a=main_breaker,
|
|
647
638
|
wifi_ssid=wifi_ssid,
|
|
648
639
|
vendor_cloud=vendor_cloud,
|
|
649
|
-
panel_size=panel_size,
|
|
650
640
|
power_flow_pv=power_flow_pv,
|
|
651
641
|
power_flow_battery=power_flow_battery,
|
|
652
642
|
power_flow_grid=power_flow_grid,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|