Skip to content

Commit 30ed264

Browse files
authored
Merge pull request #117 from SpanPanel/2_3_0
2 3 0 extract simulation, add diagnostics for schema drift
2 parents 5989a2b + 26e28dd commit 30ed264

33 files changed

Lines changed: 864 additions & 4155 deletions

docs/simulation.md

Lines changed: 0 additions & 703 deletions
This file was deleted.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "span-panel-api"
3-
version = "2.2.4"
3+
version = "2.3.0"
44
description = "A client library for SPAN Panel API"
55
authors = [
66
{name = "SpanPanel"}

src/span_panel_api/__init__.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from .auth import download_ca_cert, get_homie_schema, regenerate_passphrase, register_v2
88
from .detection import DetectionResult, detect_api_version
99
from .exceptions import (
10-
SimulationConfigurationError,
1110
SpanPanelAPIError,
1211
SpanPanelAuthError,
1312
SpanPanelConnectionError,
@@ -18,6 +17,7 @@
1817
)
1918
from .factory import create_span_client
2019
from .models import (
20+
FieldMetadata,
2121
SpanBatterySnapshot,
2222
SpanCircuitSnapshot,
2323
SpanEvseSnapshot,
@@ -43,9 +43,8 @@
4343
SpanPanelClientProtocol,
4444
StreamingCapableProtocol,
4545
)
46-
from .simulation import DynamicSimulationEngine
4746

48-
__version__ = "2.2.1"
47+
__version__ = "2.3.0"
4948
# fmt: off
5049
__all__ = [ # noqa: RUF022
5150
# Protocols
@@ -54,6 +53,8 @@
5453
"PanelControlProtocol",
5554
"SpanPanelClientProtocol",
5655
"StreamingCapableProtocol",
56+
# Metadata
57+
"FieldMetadata",
5758
# Snapshots
5859
"SpanBatterySnapshot",
5960
"SpanCircuitSnapshot",
@@ -84,15 +85,12 @@
8485
"suggest_balanced_pairing",
8586
"validate_solar_tabs",
8687
# Exceptions
87-
"SimulationConfigurationError",
8888
"SpanPanelAPIError",
8989
"SpanPanelAuthError",
9090
"SpanPanelConnectionError",
9191
"SpanPanelError",
9292
"SpanPanelServerError",
9393
"SpanPanelTimeoutError",
9494
"SpanPanelValidationError",
95-
# Simulation
96-
"DynamicSimulationEngine",
9795
]
9896
# fmt: on

src/span_panel_api/auth.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@
1818
from .models import V2AuthResponse, V2HomieSchema, V2StatusInfo
1919

2020

21+
def _build_url(host: str, port: int, path: str) -> str:
22+
"""Build an HTTP URL, omitting the port when it is the default (80)."""
23+
if port == 80:
24+
return f"http://{host}{path}"
25+
return f"http://{host}:{port}{path}"
26+
27+
2128
def _str(val: object) -> str:
2229
"""Extract a string from a JSON-decoded value."""
2330
return str(val) if val is not None else ""
@@ -37,6 +44,7 @@ async def register_v2(
3744
name: str,
3845
passphrase: str | None = None,
3946
timeout: float = 10.0,
47+
port: int = 80,
4048
) -> V2AuthResponse:
4149
"""Register with the SPAN Panel v2 API and obtain access + MQTT credentials.
4250
@@ -49,6 +57,7 @@ async def register_v2(
4957
name: Client display name base (e.g., "home-assistant"); a UUID suffix is appended
5058
passphrase: Panel passphrase (printed on label or set by owner). None for door bypass.
5159
timeout: Request timeout in seconds
60+
port: HTTP port of the panel bootstrap API
5261
5362
Returns:
5463
V2AuthResponse with access token and MQTT broker credentials
@@ -59,7 +68,7 @@ async def register_v2(
5968
SpanPanelTimeoutError: Request timed out
6069
SpanPanelAPIError: Unexpected response
6170
"""
62-
url = f"http://{host}/api/v2/auth/register"
71+
url = _build_url(host, port, "/api/v2/auth/register")
6372
# The panel requires unique client names — append a random suffix.
6473
# The passphrase field must be "hopPassphrase" per the SPAN v2 API spec.
6574
suffix = uuid.uuid4().hex[:8]
@@ -99,12 +108,13 @@ async def register_v2(
99108
)
100109

101110

102-
async def download_ca_cert(host: str, timeout: float = 10.0) -> str:
111+
async def download_ca_cert(host: str, timeout: float = 10.0, port: int = 80) -> str:
103112
"""Download the PEM CA certificate from the SPAN Panel.
104113
105114
Args:
106115
host: IP address or hostname of the SPAN Panel
107116
timeout: Request timeout in seconds
117+
port: HTTP port of the panel bootstrap API
108118
109119
Returns:
110120
PEM-encoded CA certificate as a string
@@ -114,7 +124,7 @@ async def download_ca_cert(host: str, timeout: float = 10.0) -> str:
114124
SpanPanelTimeoutError: Request timed out
115125
SpanPanelAPIError: Unexpected response or invalid PEM
116126
"""
117-
url = f"http://{host}/api/v2/certificate/ca"
127+
url = _build_url(host, port, "/api/v2/certificate/ca")
118128

119129
try:
120130
async with httpx.AsyncClient(timeout=timeout, verify=False) as client: # nosec B501
@@ -134,14 +144,15 @@ async def download_ca_cert(host: str, timeout: float = 10.0) -> str:
134144
return pem
135145

136146

137-
async def get_homie_schema(host: str, timeout: float = 10.0) -> V2HomieSchema:
147+
async def get_homie_schema(host: str, timeout: float = 10.0, port: int = 80) -> V2HomieSchema:
138148
"""Fetch the Homie property schema from the SPAN Panel.
139149
140150
This endpoint is unauthenticated.
141151
142152
Args:
143153
host: IP address or hostname of the SPAN Panel
144154
timeout: Request timeout in seconds
155+
port: HTTP port of the panel bootstrap API
145156
146157
Returns:
147158
V2HomieSchema with firmware version, schema hash, and type definitions
@@ -151,7 +162,7 @@ async def get_homie_schema(host: str, timeout: float = 10.0) -> V2HomieSchema:
151162
SpanPanelTimeoutError: Request timed out
152163
SpanPanelAPIError: Unexpected response
153164
"""
154-
url = f"http://{host}/api/v2/homie/schema"
165+
url = _build_url(host, port, "/api/v2/homie/schema")
155166

156167
try:
157168
async with httpx.AsyncClient(timeout=timeout, verify=False) as client: # nosec B501
@@ -187,7 +198,7 @@ async def get_homie_schema(host: str, timeout: float = 10.0) -> V2HomieSchema:
187198
)
188199

189200

190-
async def regenerate_passphrase(host: str, token: str, timeout: float = 10.0) -> str:
201+
async def regenerate_passphrase(host: str, token: str, timeout: float = 10.0, port: int = 80) -> str:
191202
"""Rotate the MQTT broker password on the SPAN Panel.
192203
193204
After this call, the previous broker password is invalidated.
@@ -198,6 +209,7 @@ async def regenerate_passphrase(host: str, token: str, timeout: float = 10.0) ->
198209
host: IP address or hostname of the SPAN Panel
199210
token: Valid JWT access token
200211
timeout: Request timeout in seconds
212+
port: HTTP port of the panel bootstrap API
201213
202214
Returns:
203215
New MQTT broker password
@@ -208,7 +220,7 @@ async def regenerate_passphrase(host: str, token: str, timeout: float = 10.0) ->
208220
SpanPanelTimeoutError: Request timed out
209221
SpanPanelAPIError: Unexpected response
210222
"""
211-
url = f"http://{host}/api/v2/auth/passphrase"
223+
url = _build_url(host, port, "/api/v2/auth/passphrase")
212224
headers = {"Authorization": f"Bearer {token}"}
213225

214226
try:
@@ -229,12 +241,13 @@ async def regenerate_passphrase(host: str, token: str, timeout: float = 10.0) ->
229241
return _str(data["ebusBrokerPassword"])
230242

231243

232-
async def get_v2_status(host: str, timeout: float = 5.0) -> V2StatusInfo:
244+
async def get_v2_status(host: str, timeout: float = 5.0, port: int = 80) -> V2StatusInfo:
233245
"""Lightweight v2 status probe (unauthenticated).
234246
235247
Args:
236248
host: IP address or hostname of the SPAN Panel
237249
timeout: Request timeout in seconds
250+
port: HTTP port of the panel bootstrap API
238251
239252
Returns:
240253
V2StatusInfo with serial number and firmware version
@@ -244,7 +257,7 @@ async def get_v2_status(host: str, timeout: float = 5.0) -> V2StatusInfo:
244257
SpanPanelTimeoutError: Request timed out
245258
SpanPanelAPIError: Unexpected response or non-v2 panel
246259
"""
247-
url = f"http://{host}/api/v2/status"
260+
url = _build_url(host, port, "/api/v2/status")
248261

249262
try:
250263
async with httpx.AsyncClient(timeout=timeout, verify=False) as client: # nosec B501

src/span_panel_api/detection.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@
1414
from .models import V2StatusInfo
1515

1616

17+
def _build_url(host: str, port: int, path: str) -> str:
18+
"""Build an HTTP URL, omitting the port when it is the default (80)."""
19+
if port == 80:
20+
return f"http://{host}{path}"
21+
return f"http://{host}:{port}{path}"
22+
23+
1724
@dataclass(frozen=True, slots=True)
1825
class DetectionResult:
1926
"""Result of probing a SPAN Panel for API version support."""
@@ -22,7 +29,7 @@ class DetectionResult:
2229
status_info: V2StatusInfo | None = None # populated when v2 detected
2330

2431

25-
async def detect_api_version(host: str, timeout: float = 5.0) -> DetectionResult:
32+
async def detect_api_version(host: str, timeout: float = 5.0, port: int = 80) -> DetectionResult:
2633
"""Detect SPAN Panel API version.
2734
2835
Probes GET /api/v2/status (unauthenticated).
@@ -32,11 +39,12 @@ async def detect_api_version(host: str, timeout: float = 5.0) -> DetectionResult
3239
Args:
3340
host: IP address or hostname of the SPAN Panel
3441
timeout: Request timeout in seconds
42+
port: HTTP port of the panel bootstrap API
3543
3644
Returns:
3745
DetectionResult indicating which API version is available
3846
"""
39-
url = f"http://{host}/api/v2/status"
47+
url = _build_url(host, port, "/api/v2/status")
4048
try:
4149
async with httpx.AsyncClient(timeout=timeout, verify=False) as client: # nosec B501
4250
response = await client.get(url)

src/span_panel_api/exceptions.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,3 @@ def __str__(self) -> str:
3434

3535
class SpanPanelServerError(SpanPanelAPIError):
3636
"""Server error (500)."""
37-
38-
39-
class SimulationConfigurationError(SpanPanelError):
40-
"""Simulation configuration is invalid or missing required data."""

src/span_panel_api/models.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ class SpanPVSnapshot:
4545

4646
vendor_name: str | None = None # pv/vendor-name
4747
product_name: str | None = None # pv/product-name
48-
nameplate_capacity_kw: float | None = None # pv/nameplate-capacity (kW)
48+
nameplate_capacity_w: float | None = None # pv/nameplate-capacity (W)
49+
feed_circuit_id: str | None = None # pv/feed (normalized circuit ID)
50+
relative_position: str | None = None # pv/relative-position (IN_PANEL | UPSTREAM | DOWNSTREAM)
4951

5052

5153
@dataclass(frozen=True, slots=True)
@@ -84,6 +86,19 @@ class SpanBatterySnapshot:
8486
connected: bool | None = None # bess/connected
8587

8688

89+
@dataclass(frozen=True, slots=True)
90+
class FieldMetadata:
91+
"""Schema-derived metadata for a single snapshot field.
92+
93+
Exposed by the client in a dict keyed by snapshot field path
94+
(e.g. ``"panel.instant_grid_power_w"``). The integration compares
95+
these values against its sensor definitions for unit validation.
96+
"""
97+
98+
unit: str | None # "W", "A", "V", "%", "kWh", None
99+
datatype: str # "float", "integer", "enum", "string", "boolean"
100+
101+
87102
@dataclass(frozen=True, slots=True)
88103
class V2AuthResponse:
89104
"""Response from POST /api/v2/auth/register."""

src/span_panel_api/mqtt/client.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
1313

1414
from ..auth import get_homie_schema
1515
from ..exceptions import SpanPanelConnectionError, SpanPanelServerError
16-
from ..models import SpanPanelSnapshot
16+
from ..models import FieldMetadata, SpanPanelSnapshot
1717
from ..protocol import PanelCapability
1818
from .connection import AsyncMqttBridge
1919
from .const import MQTT_READY_TIMEOUT_S, PROPERTY_SET_TOPIC_FMT, TYPE_CORE, WILDCARD_TOPIC_FMT
20+
from .field_metadata import build_field_metadata, log_schema_drift
2021
from .homie import HomieDeviceConsumer
2122
from .models import MqttClientConfig
2223

@@ -37,11 +38,13 @@ def __init__(
3738
serial_number: str,
3839
broker_config: MqttClientConfig,
3940
snapshot_interval: float = 1.0,
41+
panel_http_port: int = 80,
4042
) -> None:
4143
self._host = host
4244
self._serial_number = serial_number
4345
self._broker_config = broker_config
4446
self._snapshot_interval = snapshot_interval
47+
self._panel_http_port = panel_http_port
4548

4649
self._bridge: AsyncMqttBridge | None = None
4750
self._homie: HomieDeviceConsumer | None = None
@@ -51,6 +54,9 @@ def __init__(
5154
self._loop: asyncio.AbstractEventLoop | None = None
5255
self._background_tasks: set[asyncio.Task[None]] = set()
5356
self._snapshot_timer: asyncio.TimerHandle | None = None
57+
self._field_metadata: dict[str, FieldMetadata] | None = None
58+
self._schema_hash: str | None = None
59+
self._previous_schema_types: dict[str, dict[str, object]] | None = None
5460

5561
def _require_homie(self) -> HomieDeviceConsumer:
5662
"""Return the HomieDeviceConsumer, raising if not yet connected."""
@@ -75,6 +81,15 @@ def serial_number(self) -> str:
7581
"""Return the panel serial number."""
7682
return self._serial_number
7783

84+
@property
85+
def field_metadata(self) -> dict[str, FieldMetadata] | None:
86+
"""Schema-derived metadata for snapshot fields, or None before connect().
87+
88+
Keyed by snapshot field path (e.g. ``"panel.instant_grid_power_w"``).
89+
Built once during ``connect()`` from the Homie schema.
90+
"""
91+
return self._field_metadata
92+
7893
async def connect(self) -> None:
7994
"""Connect to MQTT broker and wait for Homie device ready.
8095
@@ -92,10 +107,26 @@ async def connect(self) -> None:
92107
self._loop = asyncio.get_running_loop()
93108
self._ready_event = asyncio.Event()
94109

95-
# Fetch schema to determine panel size before processing any messages
96-
schema = await get_homie_schema(self._host)
110+
# Fetch schema to determine panel size and build field metadata
111+
schema = await get_homie_schema(self._host, port=self._panel_http_port)
97112
self._homie = HomieDeviceConsumer(self._serial_number, schema.panel_size)
98113

114+
# Detect schema drift from previous connection
115+
new_hash = schema.types_schema_hash
116+
if self._schema_hash is not None and new_hash != self._schema_hash:
117+
_LOGGER.debug(
118+
"Homie schema hash changed: %s → %s (firmware update may have modified the property schema)",
119+
self._schema_hash,
120+
new_hash,
121+
)
122+
if self._previous_schema_types is not None:
123+
log_schema_drift(self._previous_schema_types, schema.types)
124+
self._schema_hash = new_hash
125+
self._previous_schema_types = schema.types
126+
127+
# Build transport-agnostic field metadata from schema
128+
self._field_metadata = build_field_metadata(schema.types)
129+
99130
_LOGGER.debug(
100131
"MQTT: Creating bridge to %s:%s (serial=%s)",
101132
self._broker_config.broker_host,
@@ -113,6 +144,7 @@ async def connect(self) -> None:
113144
transport=self._broker_config.transport,
114145
use_tls=self._broker_config.use_tls,
115146
loop=self._loop,
147+
panel_http_port=self._panel_http_port,
116148
)
117149

118150
# Wire message handler

src/span_panel_api/mqtt/connection.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def __init__(
6161
transport: MqttTransport = "tcp",
6262
use_tls: bool = True,
6363
loop: asyncio.AbstractEventLoop | None = None,
64+
panel_http_port: int = 80,
6465
) -> None:
6566
self._host = host
6667
self._port = port
@@ -71,6 +72,7 @@ def __init__(
7172
self._transport: MqttTransport = transport
7273
self._use_tls = use_tls
7374
self._loop = loop
75+
self._panel_http_port = panel_http_port
7476

7577
self._connected = False
7678
self._client: AsyncMQTTClient | None = None
@@ -118,7 +120,7 @@ async def connect(self) -> None:
118120
ca_cert_path: Path | None = None
119121
if self._use_tls:
120122
try:
121-
pem = await download_ca_cert(self._panel_host)
123+
pem = await download_ca_cert(self._panel_host, port=self._panel_http_port)
122124
except Exception as exc:
123125
raise SpanPanelConnectionError(f"Failed to fetch CA certificate from {self._panel_host}") from exc
124126

0 commit comments

Comments
 (0)