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.
Files changed (22) hide show
  1. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/PKG-INFO +2 -1
  2. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/README.md +1 -0
  3. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/pyproject.toml +1 -1
  4. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/__init__.py +2 -0
  5. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/models.py +30 -1
  6. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/mqtt/client.py +31 -15
  7. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/mqtt/homie.py +10 -20
  8. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/LICENSE +0 -0
  9. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/auth.py +0 -0
  10. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/const.py +0 -0
  11. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/detection.py +0 -0
  12. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/exceptions.py +0 -0
  13. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/factory.py +0 -0
  14. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/mqtt/__init__.py +0 -0
  15. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/mqtt/async_client.py +0 -0
  16. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/mqtt/connection.py +0 -0
  17. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/mqtt/const.py +0 -0
  18. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/mqtt/models.py +0 -0
  19. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/phase_validation.py +0 -0
  20. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/protocol.py +0 -0
  21. {span_panel_api-2.2.2 → span_panel_api-2.2.3}/src/span_panel_api/py.typed +0 -0
  22. {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.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)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api"
3
- version = "2.2.2"
3
+ version = "2.2.3"
4
4
  description = "A client library for SPAN Panel API"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -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 = HomieDeviceConsumer(serial_number)
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. Create AsyncMqttBridge with broker credentials
76
- 2. Connect to MQTT broker
77
- 3. Subscribe to ebus/5/{serial}/#
78
- 4. Wait for $state==ready and $description parsed
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._homie.build_snapshot()
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._homie.find_node_by_type(TYPE_CORE)
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
- was_ready = self._homie.is_ready()
232
- self._homie.handle_message(topic, payload)
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 self._homie.is_ready() and self._ready_event is not None:
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 self._homie.is_ready() and self._loop is not None:
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 = self._homie.circuit_nodes_missing_names()
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 = self._homie.circuit_nodes_missing_names()
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._homie.build_snapshot()
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(self, circuits: dict[str, SpanCircuitSnapshot]) -> dict[str, SpanCircuitSnapshot]:
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
- Determines panel size from the highest occupied tab, then creates
447
- zero-power SpanCircuitSnapshot entries for unoccupied positions.
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, panel_size + 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