Add Kostal plenticore Installer login support (#133773)

* feat: Add Installer login, Add ManualCharge Switch

* remove unnecessary field

* replace strings with consts

* change to CONF and camel_case

* Improve existing code

* Add translation string

* format code

* add service code test

* format code

* format code

* remove manual charge switch

* add reconfigure config flow

* fix flow

* add return type

* add reconfigure strings

* adjust tests

* change string

* simlify tests

* add reconfigure test

* add more tests

* Fix

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Maximilian Arzberger 2025-05-14 14:05:23 +02:00 committed by GitHub
parent e413e9b93b
commit 67b9904740
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 335 additions and 29 deletions

View File

@ -12,7 +12,7 @@ from homeassistant.const import CONF_BASE, CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .const import CONF_SERVICE_CODE, DOMAIN
from .helper import get_hostname_id
_LOGGER = logging.getLogger(__name__)
@ -21,6 +21,7 @@ DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PASSWORD): str,
vol.Optional(CONF_SERVICE_CODE): str,
}
)
@ -32,8 +33,10 @@ async def test_connection(hass: HomeAssistant, data) -> str:
"""
session = async_get_clientsession(hass)
async with ApiClient(session, data["host"]) as client:
await client.login(data["password"])
async with ApiClient(session, data[CONF_HOST]) as client:
await client.login(
data[CONF_PASSWORD], service_code=data.get(CONF_SERVICE_CODE)
)
hostname_id = await get_hostname_id(client)
values = await client.get_setting_values("scb:network", hostname_id)
@ -70,3 +73,30 @@ class KostalPlenticoreConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Add reconfigure step to allow to reconfigure a config entry."""
errors = {}
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
try:
hostname = await test_connection(self.hass, user_input)
except AuthenticationException as ex:
errors[CONF_PASSWORD] = "invalid_auth"
_LOGGER.error("Error response: %s", ex)
except (ClientError, TimeoutError):
errors[CONF_HOST] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors[CONF_BASE] = "unknown"
else:
return self.async_update_reload_and_abort(
entry=self._get_reconfigure_entry(), title=hostname, data=user_input
)
return self.async_show_form(
step_id="reconfigure", data_schema=DATA_SCHEMA, errors=errors
)

View File

@ -1,3 +1,4 @@
"""Constants for the Kostal Plenticore Solar Inverter integration."""
DOMAIN = "kostal_plenticore"
CONF_SERVICE_CODE = "service_code"

View File

@ -25,7 +25,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
from .const import CONF_SERVICE_CODE, DOMAIN
from .helper import get_hostname_id
_LOGGER = logging.getLogger(__name__)
@ -60,7 +60,10 @@ class Plenticore:
async_get_clientsession(self.hass), host=self.host
)
try:
await self._client.login(self.config_entry.data[CONF_PASSWORD])
await self._client.login(
self.config_entry.data[CONF_PASSWORD],
service_code=self.config_entry.data.get(CONF_SERVICE_CODE),
)
except AuthenticationException as err:
_LOGGER.error(
"Authentication exception connecting to %s: %s", self.host, err

View File

@ -4,7 +4,15 @@
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]"
"password": "[%key:common::config_flow::data::password%]",
"service_code": "Service code"
}
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"service_code": "[%key:component::kostal_plenticore::config::step::user::data::service_code%]"
}
}
},
@ -14,7 +22,8 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
}
}

View File

@ -8,6 +8,7 @@ import pytest
from homeassistant import config_entries
from homeassistant.components.kostal_plenticore.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@ -74,7 +75,7 @@ async def test_form_g1(
return_value={"scb:network": {"Hostname": "scb"}}
)
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
@ -86,15 +87,15 @@ async def test_form_g1(
mock_apiclient_class.assert_called_once_with(ANY, "1.1.1.1")
mock_apiclient.__aenter__.assert_called_once()
mock_apiclient.__aexit__.assert_called_once()
mock_apiclient.login.assert_called_once_with("test-password")
mock_apiclient.login.assert_called_once_with("test-password", service_code=None)
mock_apiclient.get_settings.assert_called_once()
mock_apiclient.get_setting_values.assert_called_once_with(
"scb:network", "Hostname"
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "scb"
assert result2["data"] == {
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "scb"
assert result["data"] == {
"host": "1.1.1.1",
"password": "test-password",
}
@ -140,7 +141,7 @@ async def test_form_g2(
return_value={"scb:network": {"Network:Hostname": "scb"}}
)
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
@ -152,21 +153,91 @@ async def test_form_g2(
mock_apiclient_class.assert_called_once_with(ANY, "1.1.1.1")
mock_apiclient.__aenter__.assert_called_once()
mock_apiclient.__aexit__.assert_called_once()
mock_apiclient.login.assert_called_once_with("test-password")
mock_apiclient.login.assert_called_once_with("test-password", service_code=None)
mock_apiclient.get_settings.assert_called_once()
mock_apiclient.get_setting_values.assert_called_once_with(
"scb:network", "Network:Hostname"
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "scb"
assert result2["data"] == {
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "scb"
assert result["data"] == {
"host": "1.1.1.1",
"password": "test-password",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_g2_with_service_code(
hass: HomeAssistant,
mock_apiclient_class: type[ApiClient],
mock_apiclient: ApiClient,
) -> None:
"""Test the config flow for G2 models with a Service Code."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
with patch(
"homeassistant.components.kostal_plenticore.async_setup_entry",
return_value=True,
) as mock_setup_entry:
# mock of the context manager instance
mock_apiclient.login = AsyncMock()
mock_apiclient.get_settings = AsyncMock(
return_value={
"scb:network": [
SettingsData(
min="1",
max="63",
default=None,
access="readwrite",
unit=None,
id="Network:Hostname",
type="string",
),
]
}
)
mock_apiclient.get_setting_values = AsyncMock(
# G1 model has the entry id "Hostname"
return_value={"scb:network": {"Network:Hostname": "scb"}}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
"password": "test-password",
"service_code": "test-service-code",
},
)
await hass.async_block_till_done()
mock_apiclient_class.assert_called_once_with(ANY, "1.1.1.1")
mock_apiclient.__aenter__.assert_called_once()
mock_apiclient.__aexit__.assert_called_once()
mock_apiclient.login.assert_called_once_with(
"test-password", service_code="test-service-code"
)
mock_apiclient.get_settings.assert_called_once()
mock_apiclient.get_setting_values.assert_called_once_with(
"scb:network", "Network:Hostname"
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "scb"
assert result["data"] == {
"host": "1.1.1.1",
"password": "test-password",
"service_code": "test-service-code",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_invalid_auth(hass: HomeAssistant) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
@ -189,7 +260,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None:
mock_api_class.return_value = mock_api
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
@ -197,8 +268,8 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None:
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"password": "invalid_auth"}
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"password": "invalid_auth"}
async def test_form_cannot_connect(hass: HomeAssistant) -> None:
@ -223,7 +294,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None:
mock_api_class.return_value = mock_api
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
@ -231,8 +302,8 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None:
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"host": "cannot_connect"}
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"host": "cannot_connect"}
async def test_form_unexpected_error(hass: HomeAssistant) -> None:
@ -257,7 +328,7 @@ async def test_form_unexpected_error(hass: HomeAssistant) -> None:
mock_api_class.return_value = mock_api
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
@ -265,8 +336,8 @@ async def test_form_unexpected_error(hass: HomeAssistant) -> None:
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "unknown"}
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "unknown"}
async def test_already_configured(hass: HomeAssistant) -> None:
@ -281,7 +352,7 @@ async def test_already_configured(hass: HomeAssistant) -> None:
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
@ -289,5 +360,197 @@ async def test_already_configured(hass: HomeAssistant) -> None:
},
)
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "already_configured"
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_reconfigure(
hass: HomeAssistant,
mock_apiclient_class: type[ApiClient],
mock_apiclient: ApiClient,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the config flow for G1 models."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
assert result["errors"] == {}
# mock of the context manager instance
mock_apiclient.login = AsyncMock()
mock_apiclient.get_settings = AsyncMock(
return_value={
"scb:network": [
SettingsData(
min="1",
max="63",
default=None,
access="readwrite",
unit=None,
id="Hostname",
type="string",
),
]
}
)
mock_apiclient.get_setting_values = AsyncMock(
# G1 model has the entry id "Hostname"
return_value={"scb:network": {"Hostname": "scb"}}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
"password": "test-password",
},
)
await hass.async_block_till_done()
mock_apiclient_class.assert_called_once_with(ANY, "1.1.1.1")
mock_apiclient.__aenter__.assert_called_once()
mock_apiclient.__aexit__.assert_called_once()
mock_apiclient.login.assert_called_once_with("test-password", service_code=None)
mock_apiclient.get_settings.assert_called_once()
mock_apiclient.get_setting_values.assert_called_once_with("scb:network", "Hostname")
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
# changed entry
assert mock_config_entry.data[CONF_HOST] == "1.1.1.1"
assert mock_config_entry.data[CONF_PASSWORD] == "test-password"
async def test_reconfigure_invalid_auth(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test we handle invalid auth while reconfiguring."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
with patch(
"homeassistant.components.kostal_plenticore.config_flow.ApiClient"
) as mock_api_class:
# mock of the context manager instance
mock_api_ctx = MagicMock()
mock_api_ctx.login = AsyncMock(
side_effect=AuthenticationException(404, "invalid user"),
)
# mock of the return instance of ApiClient
mock_api = MagicMock()
mock_api.__aenter__.return_value = mock_api_ctx
mock_api.__aexit__.return_value = None
mock_api_class.return_value = mock_api
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
"password": "test-password",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"password": "invalid_auth"}
async def test_reconfigure_cannot_connect(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test we handle cannot connect error."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
with patch(
"homeassistant.components.kostal_plenticore.config_flow.ApiClient"
) as mock_api_class:
# mock of the context manager instance
mock_api_ctx = MagicMock()
mock_api_ctx.login = AsyncMock(
side_effect=TimeoutError(),
)
# mock of the return instance of ApiClient
mock_api = MagicMock()
mock_api.__aenter__.return_value = mock_api_ctx
mock_api.__aexit__.return_value = None
mock_api_class.return_value = mock_api
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
"password": "test-password",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"host": "cannot_connect"}
async def test_reconfigure_unexpected_error(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test we handle unexpected error."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
with patch(
"homeassistant.components.kostal_plenticore.config_flow.ApiClient"
) as mock_api_class:
# mock of the context manager instance
mock_api_ctx = MagicMock()
mock_api_ctx.login = AsyncMock(
side_effect=Exception(),
)
# mock of the return instance of ApiClient
mock_api = MagicMock()
mock_api.__aenter__.return_value = mock_api_ctx
mock_api.__aexit__.return_value = None
mock_api_class.return_value = mock_api
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
"password": "test-password",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "unknown"}
async def test_reconfigure_already_configured(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test we handle already configured error."""
mock_config_entry.add_to_hass(hass)
MockConfigEntry(
domain="kostal_plenticore",
data={CONF_HOST: "1.1.1.1", CONF_PASSWORD: "foobar"},
unique_id="112233445566",
).add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "1.1.1.1",
CONF_PASSWORD: "test-password",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"