Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions homeassistant/components/solax/__init__.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
"""The solax component."""

import asyncio
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Any

from solax import InverterResponse, RealTimeAPI, real_time_api
from solax import InverterResponse, RealTimeAPI, discover
from solax.inverter import InverterError

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, Platform
from homeassistant.const import (
CONF_IP_ADDRESS,
CONF_MODEL,
CONF_PASSWORD,
CONF_PORT,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import UpdateFailed

from .const import INVERTER_MODELS
from .coordinator import SolaxDataUpdateCoordinator

PLATFORMS = [Platform.SENSOR]
Expand All @@ -36,14 +45,20 @@ class SolaxData:
async def async_setup_entry(hass: HomeAssistant, entry: SolaxConfigEntry) -> bool:
"""Set up the sensors from a ConfigEntry."""

kwargs: dict[str, Any] = {"return_when": asyncio.FIRST_COMPLETED}
if model := entry.data.get(CONF_MODEL):
kwargs["inverters"] = [INVERTER_MODELS[model].load()]

try:
api = await real_time_api(
inverter = await discover(
entry.data[CONF_IP_ADDRESS],
entry.data[CONF_PORT],
entry.data[CONF_PASSWORD],
**kwargs,
)
except Exception as err:
raise ConfigEntryNotReady from err
api = RealTimeAPI(inverter)

async def _async_update() -> InverterResponse:
try:
Expand Down
118 changes: 98 additions & 20 deletions homeassistant/components/solax/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
"""Config flow for solax integration."""

import asyncio
import logging
from typing import Any, override

from solax import real_time_api
from solax import RealTimeAPI, discover
from solax.discovery import DiscoveryError
from solax.inverter import Inverter, InverterError
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT
from homeassistant.helpers import config_validation as cv
from homeassistant.const import CONF_IP_ADDRESS, CONF_MODEL, CONF_PASSWORD, CONF_PORT
from homeassistant.helpers import config_validation as cv, selector

from .const import DOMAIN
from .const import DOMAIN, model_name_for_inverter

_LOGGER = logging.getLogger(__name__)

Expand All @@ -27,42 +29,118 @@
)


async def validate_api(data) -> str:
"""Validate the credentials."""
class SolaxConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Solax.

api = await real_time_api(
data[CONF_IP_ADDRESS], data[CONF_PORT], data[CONF_PASSWORD]
)
response = await api.get_data()
return response.serial_number
Two step flow:
1. Get connection data and do a discovery
2. If multiple inverters are returned from step 1, then show them to the user to choose one. If one is returned, skip this step.
"""

def __init__(self) -> None:
"""Initialize the flow."""
super().__init__()
self._connection_data: dict[str, Any] = {}
self._potential_types: dict[str, Inverter] = {}

class SolaxConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Solax."""
def _select_model_schema(self) -> vol.Schema:
"""Return the schema listing only the discovered inverters."""
return vol.Schema(
{
vol.Required(CONF_MODEL): selector.SelectSelector(
Comment thread
kdehairy marked this conversation as resolved.
selector.SelectSelectorConfig(
options=sorted(self._potential_types),
mode=selector.SelectSelectorMode.DROPDOWN,
)
)
}
)

async def _async_finalize(
self, model: str, inverter: Inverter, *, step_id: str, data_schema: vol.Schema
) -> ConfigFlowResult:
"""Fetch the serial number and create the entry, or redisplay on error."""
errors: dict[str, str] = {}
try:
response = await RealTimeAPI(inverter).get_data()
except ConnectionError, DiscoveryError:
Comment thread
kdehairy marked this conversation as resolved.
errors["base"] = "cannot_connect"
except InverterError as err:
_LOGGER.debug("Inverter communication error: %s", err)
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(response.serial_number)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=response.serial_number,
data={**self._connection_data, CONF_MODEL: model},
)

return self.async_show_form(
step_id=step_id, data_schema=data_schema, errors=errors
)

@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, Any] = {}
"""Shows the connection form, and do the discovery.

If discovery resulted in multiple inverters, show the select_model form,
If only one skip and finalize.
"""
if user_input is None:
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
)

errors: dict[str, str] = {}
try:
serial_number = await validate_api(user_input)
potentials = await discover(
user_input[CONF_IP_ADDRESS],
user_input[CONF_PORT],
user_input[CONF_PASSWORD],
return_when=asyncio.ALL_COMPLETED,
)
except ConnectionError, DiscoveryError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(serial_number)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=serial_number, data=user_input)
self._potential_types = {
model_name_for_inverter(inverter): inverter for inverter in potentials
}
self._connection_data = user_input

if len(self._potential_types) > 1:
return await self.async_step_select_model()

model, inverter = next(iter(self._potential_types.items()))
return await self._async_finalize(
model, inverter, step_id="user", data_schema=STEP_USER_DATA_SCHEMA
)

return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

async def async_step_select_model(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle model selection when multiple inverters matched."""
if user_input is not None:
model = user_input[CONF_MODEL]
return await self._async_finalize(
model,
self._potential_types[model],
step_id="select_model",
data_schema=self._select_model_schema(),
)

return self.async_show_form(
step_id="select_model", data_schema=self._select_model_schema()
)
19 changes: 19 additions & 0 deletions homeassistant/components/solax/const.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
"""Constants for the solax integration."""

from importlib.metadata import EntryPoint, entry_points

from solax.inverter import Inverter

DOMAIN = "solax"

MANUFACTURER = "SolaX Power"

SOLAX_INVERTER_ENTRY_POINT_GROUP = "solax.inverter"

INVERTER_MODELS: dict[str, EntryPoint] = {
ep.name: ep for ep in entry_points(group=SOLAX_INVERTER_ENTRY_POINT_GROUP)
}
Comment thread
kdehairy marked this conversation as resolved.


def model_name_for_inverter(inverter: Inverter) -> str:
"""Return the INVERTER_MODELS key matching a discovered inverter instance."""
return next(
name
for name, entry_point in INVERTER_MODELS.items()
if isinstance(inverter, entry_point.load())
)
6 changes: 5 additions & 1 deletion homeassistant/components/solax/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from . import SolaxConfigEntry
from .const import DOMAIN, MANUFACTURER
from .const import DOMAIN, MANUFACTURER, model_name_for_inverter
from .coordinator import SolaxDataUpdateCoordinator

DEFAULT_PORT = 80
Expand Down Expand Up @@ -97,6 +97,7 @@ async def async_setup_entry(
resp = coordinator.data
serial = resp.serial_number
version = resp.version
model = model_name_for_inverter(api.inverter)
entities: list[InverterSensorEntity] = []
for sensor, (idx, measurement) in api.inverter.sensor_map().items():
description = SENSOR_DESCRIPTIONS[(measurement.unit, measurement.is_monotonic)]
Expand All @@ -109,6 +110,7 @@ async def async_setup_entry(
uid,
serial,
version,
model,
sensor,
description,
)
Expand All @@ -128,6 +130,7 @@ def __init__(
uid: str,
serial: str,
version: str,
model: str,
key: str,
entity_description: SensorEntityDescription,
) -> None:
Expand All @@ -140,6 +143,7 @@ def __init__(
identifiers={(DOMAIN, serial)},
manufacturer=MANUFACTURER,
name=f"{manufacturer} {serial}",
model=model,
sw_version=version,
)
self.key = key
Expand Down
8 changes: 8 additions & 0 deletions homeassistant/components/solax/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"select_model": {
"data": {
"model": "Inverter model"
},
"data_description": {
"model": "Auto-discovery matched multiple inverter models at this address. Select the model that matches your device."
}
},
"user": {
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]",
Expand Down
22 changes: 22 additions & 0 deletions tests/components/solax/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Fixtures for the solax integration tests."""

import pytest

from homeassistant.components.solax.const import DOMAIN
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT

from tests.common import MockConfigEntry


@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return a mock solax config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={
CONF_IP_ADDRESS: "192.168.1.87",
CONF_PORT: 80,
CONF_PASSWORD: "password",
},
unique_id="ABCDEFGHIJ",
)
Loading
Loading