Add SolarEdge Optimizers Home Assistant integration
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Jens Neuber
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# SolarEdge Optimizers for Home Assistant
|
||||
|
||||
Diese Custom Integration liest den lokalen Cache der
|
||||
[SolarEdge Optimizer Data App](https://git.jensneuber.de/jens/solaredge-optimizers)
|
||||
und stellt jeden Leistungsoptimierer als eigenes Home-Assistant-Gerät bereit.
|
||||
|
||||
Pro Optimierer werden zwei Sensoren angelegt:
|
||||
|
||||
- **Tagesenergie** in Wh (`total_increasing`)
|
||||
- **Aktuelle Leistung** in W (`measurement`)
|
||||
|
||||
Die Seriennummer bleibt die stabile Gerätekennung. Optimierer-ID, String-ID,
|
||||
Wechselrichter-ID und Zeitpunkt der letzten Messung stehen als Attribute zur
|
||||
Verfügung.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Home Assistant 2026.6 oder neuer
|
||||
- SolarEdge Optimizer Data App 0.2.0 oder neuer
|
||||
- eine laufende und konfigurierte App mit bereits verfügbaren Optimizer-Daten
|
||||
|
||||
Die SolarEdge-Zugangsdaten bleiben ausschließlich in der App-Konfiguration.
|
||||
Die Integration spricht nur mit der lokalen App-API.
|
||||
|
||||
## Installation mit HACS
|
||||
|
||||
1. In HACS **Integrationen** öffnen.
|
||||
2. Über das Menü **Benutzerdefinierte Repositories** öffnen.
|
||||
3. `https://git.jensneuber.de/jens/ha-solaredge-optimizers` als Typ
|
||||
**Integration** hinzufügen.
|
||||
4. **SolarEdge Optimizers** installieren.
|
||||
5. Home Assistant neu starten.
|
||||
|
||||
Alternativ den Ordner `custom_components/solaredge_optimizers` nach
|
||||
`/config/custom_components/solaredge_optimizers` kopieren und Home Assistant
|
||||
neu starten.
|
||||
|
||||
## Einrichtung
|
||||
|
||||
Bei Home Assistant OS/Supervised meldet sich die App automatisch bei Home
|
||||
Assistant. Unter **Einstellungen → Geräte & Dienste** erscheint anschließend
|
||||
eine gefundene Integration **SolarEdge Optimizers**, die nur noch bestätigt
|
||||
werden muss.
|
||||
|
||||
Falls keine automatische Erkennung verfügbar ist:
|
||||
|
||||
1. **Einstellungen → Geräte & Dienste → Integration hinzufügen** öffnen.
|
||||
2. Nach **SolarEdge Optimizers** suchen.
|
||||
3. Die Basis-URL der App eintragen, beispielsweise `http://host:8099`.
|
||||
|
||||
Die Integration liest den Cache standardmäßig alle 600 Sekunden. Dieser Wert
|
||||
kann über **Neu konfigurieren** geändert werden. Dadurch entstehen keine
|
||||
zusätzlichen SolarEdge-Portalabrufe; diese steuert die App selbst.
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```shell
|
||||
uv sync
|
||||
uv run ruff check .
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""SolarEdge Optimizers integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_URL, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .api import SolarEdgeOptimizerApiClient
|
||||
from .const import CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
|
||||
from .coordinator import SolarEdgeOptimizerCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
type SolarEdgeOptimizerConfigEntry = ConfigEntry[SolarEdgeOptimizerCoordinator]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: SolarEdgeOptimizerConfigEntry
|
||||
) -> bool:
|
||||
"""Set up SolarEdge Optimizers from a config entry."""
|
||||
scan_seconds = entry.data.get(
|
||||
CONF_SCAN_INTERVAL, int(DEFAULT_SCAN_INTERVAL.total_seconds())
|
||||
)
|
||||
client = SolarEdgeOptimizerApiClient(
|
||||
entry.data[CONF_URL], async_get_clientsession(hass)
|
||||
)
|
||||
coordinator = SolarEdgeOptimizerCoordinator(
|
||||
hass,
|
||||
client,
|
||||
timedelta(seconds=scan_seconds),
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
entry.async_on_unload(entry.add_update_listener(_async_reload_entry))
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: SolarEdgeOptimizerConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def _async_reload_entry(
|
||||
hass: HomeAssistant, entry: SolarEdgeOptimizerConfigEntry
|
||||
) -> None:
|
||||
"""Reload after the entry is reconfigured."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
@@ -0,0 +1,225 @@
|
||||
"""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}
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Config flow for SolarEdge Optimizers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, override
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
|
||||
|
||||
from .api import (
|
||||
OptimizerSnapshot,
|
||||
SolarEdgeOptimizerApiClient,
|
||||
SolarEdgeOptimizerConnectionError,
|
||||
SolarEdgeOptimizerInvalidResponseError,
|
||||
normalize_base_url,
|
||||
)
|
||||
from .const import (
|
||||
CONF_SCAN_INTERVAL,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MIN_SCAN_INTERVAL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(
|
||||
hass: HomeAssistant, data: dict[str, Any]
|
||||
) -> tuple[str, OptimizerSnapshot]:
|
||||
"""Validate user input and return the normalized URL and snapshot."""
|
||||
url = normalize_base_url(data[CONF_URL])
|
||||
client = SolarEdgeOptimizerApiClient(url, async_get_clientsession(hass))
|
||||
return url, await client.async_get_optimizers()
|
||||
|
||||
|
||||
def _schema(defaults: dict[str, Any]) -> vol.Schema:
|
||||
"""Return the manual and reconfigure schema."""
|
||||
url_marker: vol.Marker
|
||||
if CONF_URL in defaults:
|
||||
url_marker = vol.Required(CONF_URL, default=defaults[CONF_URL])
|
||||
else:
|
||||
url_marker = vol.Required(CONF_URL)
|
||||
return vol.Schema(
|
||||
{
|
||||
url_marker: str,
|
||||
vol.Required(
|
||||
CONF_SCAN_INTERVAL,
|
||||
default=defaults.get(
|
||||
CONF_SCAN_INTERVAL,
|
||||
int(DEFAULT_SCAN_INTERVAL.total_seconds()),
|
||||
),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=MIN_SCAN_INTERVAL_SECONDS)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SolarEdgeOptimizersConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for SolarEdge Optimizers."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the flow."""
|
||||
self._discovered_url: str | None = None
|
||||
self._discovered_name: str | None = None
|
||||
|
||||
@override
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle manual setup."""
|
||||
return await self._async_handle_form("user", user_input)
|
||||
|
||||
@override
|
||||
async def async_step_hassio(
|
||||
self, discovery_info: HassioServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle discovery from the Home Assistant App."""
|
||||
host = discovery_info.config.get("host")
|
||||
port = discovery_info.config.get("port", DEFAULT_PORT)
|
||||
if not isinstance(host, str) or not host:
|
||||
return self.async_abort(reason="invalid_discovery")
|
||||
try:
|
||||
port = int(port)
|
||||
self._discovered_url = normalize_base_url(f"http://{host}:{port}")
|
||||
except TypeError, ValueError:
|
||||
return self.async_abort(reason="invalid_discovery")
|
||||
|
||||
self._discovered_name = discovery_info.name
|
||||
self.context["title_placeholders"] = {"name": discovery_info.name}
|
||||
return await self.async_step_hassio_confirm()
|
||||
|
||||
async def async_step_hassio_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm App discovery."""
|
||||
if user_input is None:
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_show_form(
|
||||
step_id="hassio_confirm",
|
||||
description_placeholders={"name": self._discovered_name or "App"},
|
||||
)
|
||||
|
||||
assert self._discovered_url is not None
|
||||
data = {
|
||||
CONF_URL: self._discovered_url,
|
||||
CONF_SCAN_INTERVAL: int(DEFAULT_SCAN_INTERVAL.total_seconds()),
|
||||
}
|
||||
try:
|
||||
url, snapshot = await validate_input(self.hass, data)
|
||||
except SolarEdgeOptimizerConnectionError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except SolarEdgeOptimizerInvalidResponseError:
|
||||
return self.async_abort(reason="invalid_response")
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception during App discovery")
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
await self.async_set_unique_id(snapshot.site_id)
|
||||
self._abort_if_unique_id_configured(updates={CONF_URL: url})
|
||||
return self.async_create_entry(
|
||||
title=f"SolarEdge Site {snapshot.site_id}", data={**data, CONF_URL: url}
|
||||
)
|
||||
|
||||
@override
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Reconfigure the App connection."""
|
||||
entry = self._get_reconfigure_entry()
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure", data_schema=_schema(dict(entry.data))
|
||||
)
|
||||
|
||||
result = await self._async_validate_form(user_input)
|
||||
if isinstance(result, dict):
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=_schema(user_input),
|
||||
errors=result,
|
||||
)
|
||||
url, snapshot = result
|
||||
if entry.unique_id != snapshot.site_id:
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=_schema(user_input),
|
||||
errors={"base": "site_id_mismatch"},
|
||||
)
|
||||
return self.async_update_reload_and_abort(
|
||||
entry,
|
||||
data_updates={**user_input, CONF_URL: url},
|
||||
)
|
||||
|
||||
async def _async_handle_form(
|
||||
self, step_id: str, user_input: dict[str, Any] | None
|
||||
) -> ConfigFlowResult:
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id=step_id, data_schema=_schema({}))
|
||||
|
||||
result = await self._async_validate_form(user_input)
|
||||
if isinstance(result, dict):
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
data_schema=_schema(user_input),
|
||||
errors=result,
|
||||
)
|
||||
|
||||
url, snapshot = result
|
||||
await self.async_set_unique_id(snapshot.site_id)
|
||||
self._abort_if_unique_id_configured(updates={CONF_URL: url})
|
||||
return self.async_create_entry(
|
||||
title=f"SolarEdge Site {snapshot.site_id}",
|
||||
data={**user_input, CONF_URL: url},
|
||||
)
|
||||
|
||||
async def _async_validate_form(
|
||||
self, user_input: dict[str, Any]
|
||||
) -> tuple[str, OptimizerSnapshot] | dict[str, str]:
|
||||
try:
|
||||
return await validate_input(self.hass, user_input)
|
||||
except ValueError:
|
||||
return {"base": "invalid_url"}
|
||||
except SolarEdgeOptimizerConnectionError:
|
||||
return {"base": "cannot_connect"}
|
||||
except SolarEdgeOptimizerInvalidResponseError:
|
||||
return {"base": "invalid_response"}
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception while validating configuration")
|
||||
return {"base": "unknown"}
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Constants for the SolarEdge Optimizers integration."""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
DOMAIN = "solaredge_optimizers"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
CONF_SCAN_INTERVAL = "scan_interval"
|
||||
DEFAULT_SCAN_INTERVAL = timedelta(minutes=10)
|
||||
MIN_SCAN_INTERVAL_SECONDS = 60
|
||||
DEFAULT_PORT = 8099
|
||||
|
||||
API_PATH = "/api/optimizers"
|
||||
API_TIMEOUT_SECONDS = 20
|
||||
@@ -0,0 +1,41 @@
|
||||
"""DataUpdateCoordinator for SolarEdge Optimizers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .api import (
|
||||
OptimizerSnapshot,
|
||||
SolarEdgeOptimizerApiClient,
|
||||
SolarEdgeOptimizerApiError,
|
||||
)
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
|
||||
class SolarEdgeOptimizerCoordinator(DataUpdateCoordinator[OptimizerSnapshot]):
|
||||
"""Coordinate shared polling of the App API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
client: SolarEdgeOptimizerApiClient,
|
||||
update_interval: timedelta,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=update_interval,
|
||||
always_update=False,
|
||||
)
|
||||
self.client = client
|
||||
|
||||
async def _async_update_data(self) -> OptimizerSnapshot:
|
||||
try:
|
||||
return await self.client.async_get_optimizers()
|
||||
except SolarEdgeOptimizerApiError as err:
|
||||
raise UpdateFailed("Error communicating with optimizer App") from err
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Diagnostics for SolarEdge Optimizers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import SolarEdgeOptimizerConfigEntry
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: SolarEdgeOptimizerConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
return {
|
||||
"entry": {
|
||||
"title": entry.title,
|
||||
"unique_id": entry.unique_id,
|
||||
"data": dict(entry.data),
|
||||
},
|
||||
"coordinator": {
|
||||
"last_update_success": entry.runtime_data.last_update_success,
|
||||
"snapshot": asdict(entry.runtime_data.data),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "solaredge_optimizers",
|
||||
"name": "SolarEdge Optimizers",
|
||||
"codeowners": ["@jensneuber"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://git.jensneuber.de/jens/ha-solaredge-optimizers",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://git.jensneuber.de/jens/ha-solaredge-optimizers/issues",
|
||||
"requirements": [],
|
||||
"version": "0.1.0"
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Sensors for SolarEdge Optimizers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfEnergy, UnitOfPower
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import SolarEdgeOptimizerConfigEntry
|
||||
from .api import OptimizerData
|
||||
from .const import DOMAIN
|
||||
from .coordinator import SolarEdgeOptimizerCoordinator
|
||||
|
||||
type OptimizerValue = float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class SolarEdgeOptimizerSensorDescription(SensorEntityDescription):
|
||||
"""Describe an optimizer sensor."""
|
||||
|
||||
value_fn: Callable[[OptimizerData], OptimizerValue]
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS = (
|
||||
SolarEdgeOptimizerSensorDescription(
|
||||
key="daily_energy",
|
||||
translation_key="daily_energy",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda optimizer: optimizer.daily_energy_wh,
|
||||
),
|
||||
SolarEdgeOptimizerSensorDescription(
|
||||
key="current_power",
|
||||
translation_key="current_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda optimizer: optimizer.current_power_w,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: Any,
|
||||
entry: SolarEdgeOptimizerConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up optimizer sensors from a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
known_serials: set[str] = set()
|
||||
|
||||
@callback
|
||||
def async_add_new_optimizers() -> None:
|
||||
new_optimizers = [
|
||||
optimizer
|
||||
for optimizer in coordinator.data.optimizers
|
||||
if optimizer.serial not in known_serials
|
||||
]
|
||||
if not new_optimizers:
|
||||
return
|
||||
known_serials.update(optimizer.serial for optimizer in new_optimizers)
|
||||
async_add_entities(
|
||||
SolarEdgeOptimizerSensor(coordinator, optimizer.serial, description)
|
||||
for optimizer in new_optimizers
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
)
|
||||
|
||||
async_add_new_optimizers()
|
||||
entry.async_on_unload(coordinator.async_add_listener(async_add_new_optimizers))
|
||||
|
||||
|
||||
class SolarEdgeOptimizerSensor(
|
||||
CoordinatorEntity[SolarEdgeOptimizerCoordinator], SensorEntity
|
||||
):
|
||||
"""Represent one optimizer reading."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: SolarEdgeOptimizerCoordinator,
|
||||
serial: str,
|
||||
description: SolarEdgeOptimizerSensorDescription,
|
||||
) -> None:
|
||||
"""Initialize a sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._serial = serial
|
||||
self._attr_unique_id = f"{coordinator.data.site_id}_{serial}_{description.key}"
|
||||
|
||||
@property
|
||||
def _optimizer(self) -> OptimizerData | None:
|
||||
return self.coordinator.data.optimizers_by_serial.get(self._serial)
|
||||
|
||||
@property
|
||||
def native_value(self) -> OptimizerValue:
|
||||
"""Return the current sensor value."""
|
||||
if (optimizer := self._optimizer) is None:
|
||||
return None
|
||||
return self.entity_description.value_fn(optimizer)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return whether this individual reading is available."""
|
||||
return super().available and self.native_value is not None
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return optimizer device information."""
|
||||
optimizer = self._optimizer
|
||||
optimizer_id = optimizer.optimizer_id if optimizer else None
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self.coordinator.data.site_id}_{self._serial}")},
|
||||
manufacturer="SolarEdge",
|
||||
model="Power Optimizer",
|
||||
name=f"Optimizer {optimizer_id or self._serial}",
|
||||
serial_number=self._serial,
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return stable optimizer metadata."""
|
||||
if (optimizer := self._optimizer) is None:
|
||||
return {}
|
||||
return {
|
||||
"optimizer_id": optimizer.optimizer_id,
|
||||
"string_id": optimizer.string_id,
|
||||
"inverter_id": optimizer.inverter_id,
|
||||
"last_measurement": optimizer.last_measurement,
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"title": "SolarEdge Optimizers",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to SolarEdge Optimizer Data",
|
||||
"description": "Enter the base URL of the SolarEdge Optimizer Data App API.",
|
||||
"data": {
|
||||
"url": "App URL",
|
||||
"scan_interval": "Polling interval (seconds)"
|
||||
},
|
||||
"data_description": {
|
||||
"url": "For example http://host:8099. The /api/optimizers path is added automatically.",
|
||||
"scan_interval": "How often Home Assistant reads the App cache. The App refreshes SolarEdge data independently."
|
||||
}
|
||||
},
|
||||
"hassio_confirm": {
|
||||
"title": "Set up {name}",
|
||||
"description": "Use the automatically discovered {name} App?"
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure SolarEdge Optimizers",
|
||||
"description": "Update the connection to the local App API.",
|
||||
"data": {
|
||||
"url": "App URL",
|
||||
"scan_interval": "Polling interval (seconds)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to the App",
|
||||
"invalid_response": "The App returned an invalid response",
|
||||
"invalid_url": "Enter a valid HTTP or HTTPS URL",
|
||||
"site_id_mismatch": "The new App URL belongs to a different SolarEdge site",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This SolarEdge site is already configured",
|
||||
"cannot_connect": "Failed to connect to the discovered App",
|
||||
"invalid_discovery": "The App supplied invalid discovery data",
|
||||
"invalid_response": "The discovered App returned an invalid response",
|
||||
"reconfigure_successful": "The integration was reconfigured",
|
||||
"unknown": "Unexpected error"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"daily_energy": {
|
||||
"name": "Daily energy"
|
||||
},
|
||||
"current_power": {
|
||||
"name": "Current power"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"title": "SolarEdge-Optimierer",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Mit SolarEdge Optimizer Data verbinden",
|
||||
"description": "Gib die Basis-URL der API der SolarEdge Optimizer Data App ein.",
|
||||
"data": {
|
||||
"url": "App-URL",
|
||||
"scan_interval": "Abrufintervall (Sekunden)"
|
||||
},
|
||||
"data_description": {
|
||||
"url": "Zum Beispiel http://host:8099. Der Pfad /api/optimizers wird automatisch ergänzt.",
|
||||
"scan_interval": "Wie oft Home Assistant den App-Cache liest. Die App aktualisiert die SolarEdge-Daten unabhängig davon."
|
||||
}
|
||||
},
|
||||
"hassio_confirm": {
|
||||
"title": "{name} einrichten",
|
||||
"description": "Die automatisch gefundene App {name} verwenden?"
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "SolarEdge-Optimierer neu konfigurieren",
|
||||
"description": "Verbindung zur lokalen App-API aktualisieren.",
|
||||
"data": {
|
||||
"url": "App-URL",
|
||||
"scan_interval": "Abrufintervall (Sekunden)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Verbindung zur App fehlgeschlagen",
|
||||
"invalid_response": "Die App hat eine ungültige Antwort geliefert",
|
||||
"invalid_url": "Gib eine gültige HTTP- oder HTTPS-URL ein",
|
||||
"site_id_mismatch": "Die neue App-URL gehört zu einer anderen SolarEdge-Site",
|
||||
"unknown": "Unerwarteter Fehler"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Diese SolarEdge-Site ist bereits eingerichtet",
|
||||
"cannot_connect": "Verbindung zur gefundenen App fehlgeschlagen",
|
||||
"invalid_discovery": "Die App hat ungültige Erkennungsdaten geliefert",
|
||||
"invalid_response": "Die gefundene App hat eine ungültige Antwort geliefert",
|
||||
"reconfigure_successful": "Die Integration wurde neu konfiguriert",
|
||||
"unknown": "Unerwarteter Fehler"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"daily_energy": {
|
||||
"name": "Tagesenergie"
|
||||
},
|
||||
"current_power": {
|
||||
"name": "Aktuelle Leistung"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"title": "SolarEdge Optimizers",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to SolarEdge Optimizer Data",
|
||||
"description": "Enter the base URL of the SolarEdge Optimizer Data App API.",
|
||||
"data": {
|
||||
"url": "App URL",
|
||||
"scan_interval": "Polling interval (seconds)"
|
||||
},
|
||||
"data_description": {
|
||||
"url": "For example http://host:8099. The /api/optimizers path is added automatically.",
|
||||
"scan_interval": "How often Home Assistant reads the App cache. The App refreshes SolarEdge data independently."
|
||||
}
|
||||
},
|
||||
"hassio_confirm": {
|
||||
"title": "Set up {name}",
|
||||
"description": "Use the automatically discovered {name} App?"
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure SolarEdge Optimizers",
|
||||
"description": "Update the connection to the local App API.",
|
||||
"data": {
|
||||
"url": "App URL",
|
||||
"scan_interval": "Polling interval (seconds)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to the App",
|
||||
"invalid_response": "The App returned an invalid response",
|
||||
"invalid_url": "Enter a valid HTTP or HTTPS URL",
|
||||
"site_id_mismatch": "The new App URL belongs to a different SolarEdge site",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This SolarEdge site is already configured",
|
||||
"cannot_connect": "Failed to connect to the discovered App",
|
||||
"invalid_discovery": "The App supplied invalid discovery data",
|
||||
"invalid_response": "The discovered App returned an invalid response",
|
||||
"reconfigure_successful": "The integration was reconfigured",
|
||||
"unknown": "Unexpected error"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"daily_energy": {
|
||||
"name": "Daily energy"
|
||||
},
|
||||
"current_power": {
|
||||
"name": "Current power"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "SolarEdge Optimizers",
|
||||
"render_readme": true,
|
||||
"homeassistant": "2026.6.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
[project]
|
||||
name = "ha-solaredge-optimizers"
|
||||
version = "0.1.0"
|
||||
description = "Home Assistant custom integration for SolarEdge optimizer data"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14.2"
|
||||
license = "MIT"
|
||||
authors = [{ name = "Jens Neuber" }]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"homeassistant>=2026.6.0",
|
||||
"pytest>=9.0.0",
|
||||
"pytest-homeassistant-custom-component>=0.13.300",
|
||||
"ruff>=0.14.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py314"
|
||||
line-length = 88
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ASYNC", "B", "E", "F", "I", "SIM", "UP"]
|
||||
ignore = [
|
||||
"COM812",
|
||||
"D203",
|
||||
"D213",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["S101"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for SolarEdge Optimizers."""
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Test fixtures for SolarEdge Optimizers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest_plugins = "pytest_homeassistant_custom_component"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def auto_enable_custom_integrations(enable_custom_integrations: None) -> None:
|
||||
"""Enable loading custom integrations in all tests."""
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Sample App API data."""
|
||||
|
||||
SAMPLE_PAYLOAD = {
|
||||
"status": "ok",
|
||||
"siteId": "4886699",
|
||||
"date": "2026-08-10",
|
||||
"timeZone": "Europe/Berlin",
|
||||
"fetchedAt": "2026-08-10T10:00:00.000Z",
|
||||
"nextRefreshAt": "2026-08-10T10:10:00.000Z",
|
||||
"optimizerCount": 2,
|
||||
"successfulOptimizerCount": 2,
|
||||
"failedOptimizerCount": 0,
|
||||
"totalDailyEnergyWh": 2468.5,
|
||||
"totalCurrentPowerW": 321.4,
|
||||
"optimizers": [
|
||||
{
|
||||
"serial": "14F28854-E2",
|
||||
"inverterId": "1",
|
||||
"stringId": "1.1",
|
||||
"optimizerId": "1.1.1",
|
||||
"dailyEnergyWh": 1234.5,
|
||||
"currentPowerW": 160.7,
|
||||
"lastMeasurement": "2026-08-10T09:59:30.000Z",
|
||||
},
|
||||
{
|
||||
"serial": "14F28855-E2",
|
||||
"inverterId": "1",
|
||||
"stringId": "1.1",
|
||||
"optimizerId": "1.1.2",
|
||||
"dailyEnergyWh": 1234.0,
|
||||
"currentPowerW": 160.7,
|
||||
"lastMeasurement": "2026-08-10T09:59:30.000Z",
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests for the App API parser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.solaredge_optimizers.api import (
|
||||
SolarEdgeOptimizerInvalidResponseError,
|
||||
normalize_base_url,
|
||||
parse_snapshot,
|
||||
)
|
||||
|
||||
from .sample_data import SAMPLE_PAYLOAD
|
||||
|
||||
|
||||
def test_parse_snapshot() -> None:
|
||||
"""Parse a valid snapshot."""
|
||||
snapshot = parse_snapshot(SAMPLE_PAYLOAD)
|
||||
|
||||
assert snapshot.site_id == "4886699"
|
||||
assert snapshot.optimizer_count == 2
|
||||
assert snapshot.optimizers[0].optimizer_id == "1.1.1"
|
||||
assert snapshot.optimizers[0].daily_energy_wh == 1234.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("http://app:8099/", "http://app:8099"),
|
||||
("http://app:8099/api/optimizers", "http://app:8099"),
|
||||
("https://example.test/prefix/", "https://example.test/prefix"),
|
||||
],
|
||||
)
|
||||
def test_normalize_base_url(value: str, expected: str) -> None:
|
||||
"""Normalize supported URLs."""
|
||||
assert normalize_base_url(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value", ["app:8099", "ftp://app/data", "http://user:password@app:8099"]
|
||||
)
|
||||
def test_reject_invalid_url(value: str) -> None:
|
||||
"""Reject invalid or credential-bearing URLs."""
|
||||
with pytest.raises(ValueError):
|
||||
normalize_base_url(value)
|
||||
|
||||
|
||||
def test_reject_duplicate_serial() -> None:
|
||||
"""Reject ambiguous optimizer records."""
|
||||
payload = deepcopy(SAMPLE_PAYLOAD)
|
||||
payload["optimizers"][1]["serial"] = payload["optimizers"][0]["serial"]
|
||||
|
||||
with pytest.raises(SolarEdgeOptimizerInvalidResponseError):
|
||||
parse_snapshot(payload)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests for the SolarEdge Optimizers config flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
|
||||
|
||||
from custom_components.solaredge_optimizers.api import parse_snapshot
|
||||
from custom_components.solaredge_optimizers.const import (
|
||||
CONF_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
from .sample_data import SAMPLE_PAYLOAD
|
||||
|
||||
|
||||
async def test_user_flow(hass: HomeAssistant) -> None:
|
||||
"""Configure the integration manually."""
|
||||
with patch(
|
||||
"custom_components.solaredge_optimizers.config_flow.validate_input",
|
||||
AsyncMock(return_value=("http://app:8099", parse_snapshot(SAMPLE_PAYLOAD))),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
data={CONF_URL: "http://app:8099", CONF_SCAN_INTERVAL: 600},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "SolarEdge Site 4886699"
|
||||
assert result["data"][CONF_URL] == "http://app:8099"
|
||||
|
||||
|
||||
async def test_hassio_discovery_flow(hass: HomeAssistant) -> None:
|
||||
"""Configure the integration from App discovery."""
|
||||
discovery = HassioServiceInfo(
|
||||
config={"host": "abc-solaredge-optimizer-data", "port": 8099},
|
||||
name="SolarEdge Optimizer Data",
|
||||
slug="abc_solaredge_optimizer_data",
|
||||
uuid="discovery-uuid",
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_HASSIO},
|
||||
data=discovery,
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "hassio_confirm"
|
||||
|
||||
with patch(
|
||||
"custom_components.solaredge_optimizers.config_flow.validate_input",
|
||||
AsyncMock(
|
||||
return_value=(
|
||||
"http://abc-solaredge-optimizer-data:8099",
|
||||
parse_snapshot(SAMPLE_PAYLOAD),
|
||||
)
|
||||
),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"][CONF_URL] == "http://abc-solaredge-optimizer-data:8099"
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests for SolarEdge optimizer sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.solaredge_optimizers.api import parse_snapshot
|
||||
from custom_components.solaredge_optimizers.const import CONF_SCAN_INTERVAL, DOMAIN
|
||||
|
||||
from .sample_data import SAMPLE_PAYLOAD
|
||||
|
||||
|
||||
async def test_optimizer_sensors(hass: HomeAssistant) -> None:
|
||||
"""Create energy and power sensors for every optimizer."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="SolarEdge Site 4886699",
|
||||
unique_id="4886699",
|
||||
data={CONF_URL: "http://app:8099", CONF_SCAN_INTERVAL: 600},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"custom_components.solaredge_optimizers.api."
|
||||
"SolarEdgeOptimizerApiClient.async_get_optimizers",
|
||||
AsyncMock(return_value=parse_snapshot(SAMPLE_PAYLOAD)),
|
||||
):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id)
|
||||
assert len(entities) == 4
|
||||
|
||||
energy_entity = next(
|
||||
entity for entity in entities if entity.unique_id.endswith("_daily_energy")
|
||||
)
|
||||
power_entity = next(
|
||||
entity for entity in entities if entity.unique_id.endswith("_current_power")
|
||||
)
|
||||
assert hass.states.get(energy_entity.entity_id).state == "1234.5"
|
||||
assert hass.states.get(power_entity.entity_id).state == "160.7"
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id)
|
||||
assert len(devices) == 2
|
||||
assert {device.serial_number for device in devices} == {
|
||||
"14F28854-E2",
|
||||
"14F28855-E2",
|
||||
}
|
||||
Reference in New Issue
Block a user