Add SolarEdge Optimizers Home Assistant integration
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user