"""Client for the SolarEdge Optimizer Data App API.""" from __future__ import annotations import asyncio from dataclasses import dataclass from datetime import date, datetime from typing import Any, Final from urllib.parse import urlsplit, urlunsplit from aiohttp import ClientError, ClientResponseError, ClientSession from .const import API_PATH, API_TIMEOUT_SECONDS _VALID_STATUSES: Final = frozenset({"ok", "partial", "stale"}) class SolarEdgeOptimizerApiError(Exception): """Base error raised by the App API client.""" class SolarEdgeOptimizerConnectionError(SolarEdgeOptimizerApiError): """The App API could not be reached.""" class SolarEdgeOptimizerInvalidResponseError(SolarEdgeOptimizerApiError): """The App returned an invalid response.""" @dataclass(frozen=True, slots=True) class OptimizerData: """Optimizer readings returned by the App.""" serial: str inverter_id: str | None string_id: str | None optimizer_id: str | None daily_energy_wh: float | None current_power_w: float | None last_measurement: datetime | None error: str | None @dataclass(frozen=True, slots=True) class OptimizerSnapshot: """A complete cached optimizer snapshot.""" status: str site_id: str date: date time_zone: str fetched_at: datetime next_refresh_at: datetime optimizer_count: int successful_optimizer_count: int failed_optimizer_count: int total_daily_energy_wh: float total_current_power_w: float optimizers: tuple[OptimizerData, ...] last_error: str | None @property def optimizers_by_serial(self) -> dict[str, OptimizerData]: """Return readings indexed by optimizer serial number.""" return {optimizer.serial: optimizer for optimizer in self.optimizers} @property def optimizers_by_id(self) -> dict[str, OptimizerData]: """Return readings indexed by logical optimizer ID.""" return { optimizer.optimizer_id: optimizer for optimizer in self.optimizers if optimizer.optimizer_id is not None } class SolarEdgeOptimizerApiClient: """Read the local SolarEdge Optimizer Data App API.""" def __init__(self, base_url: str, session: ClientSession) -> None: """Initialize the client.""" self._base_url = normalize_base_url(base_url) self._session = session @property def base_url(self) -> str: """Return the normalized App base URL.""" return self._base_url async def async_get_optimizers(self) -> OptimizerSnapshot: """Fetch and validate the latest cached snapshot.""" try: async with asyncio.timeout(API_TIMEOUT_SECONDS): async with self._session.get( f"{self._base_url}{API_PATH}", headers={"Accept": "application/json"}, ) as response: response.raise_for_status() payload = await response.json(content_type=None) except (TimeoutError, ClientError, ClientResponseError) as err: raise SolarEdgeOptimizerConnectionError from err except (ValueError, TypeError) as err: raise SolarEdgeOptimizerInvalidResponseError from err return parse_snapshot(payload) def normalize_base_url(value: str) -> str: """Validate and normalize an App base URL.""" value = value.strip().rstrip("/") if value.endswith(API_PATH): value = value[: -len(API_PATH)] parts = urlsplit(value) if parts.scheme not in {"http", "https"} or not parts.hostname: raise ValueError("URL must use http or https and include a host") if parts.username or parts.password or parts.query or parts.fragment: raise ValueError("URL must not include credentials, a query, or a fragment") path = parts.path.rstrip("/") return urlunsplit((parts.scheme, parts.netloc, path, "", "")) def parse_snapshot(payload: Any) -> OptimizerSnapshot: """Parse and validate a snapshot payload.""" if not isinstance(payload, dict): raise SolarEdgeOptimizerInvalidResponseError("Expected a JSON object") try: status = _required_string(payload, "status") if status not in _VALID_STATUSES: raise ValueError("Unsupported status") raw_optimizers = payload["optimizers"] if not isinstance(raw_optimizers, list): raise TypeError("optimizers must be a list") optimizers = tuple(_parse_optimizer(item) for item in raw_optimizers) optimizer_count = _required_int(payload, "optimizerCount") if optimizer_count != len(optimizers): raise ValueError("optimizerCount does not match optimizers") if len({optimizer.serial for optimizer in optimizers}) != len(optimizers): raise ValueError("Optimizer serial numbers must be unique") return OptimizerSnapshot( status=status, site_id=_required_string(payload, "siteId"), date=date.fromisoformat(_required_string(payload, "date")), time_zone=_required_string(payload, "timeZone"), fetched_at=_parse_datetime(payload, "fetchedAt"), next_refresh_at=_parse_datetime(payload, "nextRefreshAt"), optimizer_count=optimizer_count, successful_optimizer_count=_required_int( payload, "successfulOptimizerCount" ), failed_optimizer_count=_required_int(payload, "failedOptimizerCount"), total_daily_energy_wh=_required_number(payload, "totalDailyEnergyWh"), total_current_power_w=_required_number(payload, "totalCurrentPowerW"), optimizers=optimizers, last_error=_optional_string(payload, "lastError"), ) except (KeyError, TypeError, ValueError) as err: raise SolarEdgeOptimizerInvalidResponseError from err def _parse_optimizer(payload: Any) -> OptimizerData: if not isinstance(payload, dict): raise TypeError("Optimizer must be a JSON object") return OptimizerData( serial=_required_string(payload, "serial"), inverter_id=_nullable_string(payload, "inverterId"), string_id=_nullable_string(payload, "stringId"), optimizer_id=_nullable_string(payload, "optimizerId"), daily_energy_wh=_nullable_number(payload, "dailyEnergyWh"), current_power_w=_nullable_number(payload, "currentPowerW"), last_measurement=_nullable_datetime(payload, "lastMeasurement"), error=_optional_string(payload, "error"), ) def _required_string(payload: dict[str, Any], key: str) -> str: value = payload[key] if not isinstance(value, str) or not value: raise TypeError(f"{key} must be a non-empty string") return value def _optional_string(payload: dict[str, Any], key: str) -> str | None: value = payload.get(key) if value is None: return None if not isinstance(value, str): raise TypeError(f"{key} must be a string") return value def _nullable_string(payload: dict[str, Any], key: str) -> str | None: value = payload[key] if value is None: return None if not isinstance(value, str): raise TypeError(f"{key} must be a string or null") return value def _required_int(payload: dict[str, Any], key: str) -> int: value = payload[key] if isinstance(value, bool) or not isinstance(value, int) or value < 0: raise TypeError(f"{key} must be a non-negative integer") return value def _required_number(payload: dict[str, Any], key: str) -> float: value = payload[key] if isinstance(value, bool) or not isinstance(value, int | float): raise TypeError(f"{key} must be a number") return float(value) def _nullable_number(payload: dict[str, Any], key: str) -> float | None: value = payload[key] if value is None: return None if isinstance(value, bool) or not isinstance(value, int | float): raise TypeError(f"{key} must be a number or null") return float(value) def _parse_datetime(payload: dict[str, Any], key: str) -> datetime: return datetime.fromisoformat(_required_string(payload, key)) def _nullable_datetime(payload: dict[str, Any], key: str) -> datetime | None: value = _nullable_string(payload, key) return None if value is None else datetime.fromisoformat(value)