Skip to content
Open
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
130 changes: 121 additions & 9 deletions custom_components/givenergy_local/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
from __future__ import annotations

import asyncio
from collections.abc import Mapping
from enum import StrEnum
import socket

from typing import Any

Expand All @@ -11,19 +14,78 @@

from .const import CONF_HOST, DOMAIN, LOGGER
from .givenergy_modbus.client.client import Client
from .givenergy_modbus.exceptions import CommunicationError

STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
STEP_RECONFIGURE_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})


class ConfigFlowError(StrEnum):
"""User-visible config flow error keys."""

CANNOT_CONNECT = "cannot_connect"
INVALID_HOST = "invalid_host"
INVALID_INVERTER = "invalid_inverter"
ALREADY_CONFIGURED = "already_configured"
DIFFERENT_INVERTER = "different_inverter"


class InvalidInverterError(Exception):
"""Raised when a host responds, but not like a usable inverter."""


def _normalise_host(host: str) -> str:
"""Normalise and sanity-check host input before connecting."""
normalised_host = host.strip()

if not normalised_host:
raise ValueError("Host cannot be blank")

if "://" in normalised_host or "/" in normalised_host:
raise ValueError("Host must be a hostname or IP address")

return normalised_host


def _map_validation_error(err: Exception) -> ConfigFlowError:
"""Map internal validation failures to translated config-flow errors."""
if isinstance(err, (ValueError, socket.gaierror)):
return ConfigFlowError.INVALID_HOST

if isinstance(
err, (CommunicationError, TimeoutError, OSError, asyncio.TimeoutError)
):
return ConfigFlowError.CANNOT_CONNECT

if isinstance(err, (AttributeError, InvalidInverterError)):
return ConfigFlowError.INVALID_INVERTER

return ConfigFlowError.CANNOT_CONNECT


async def _validate_input(data: Mapping[str, Any]) -> tuple[dict[str, Any], str]:
"""Validate and normalise user input, returning clean data and inverter serial."""
validated_data = dict(data)
validated_data[CONF_HOST] = _normalise_host(str(data[CONF_HOST]))

serial_no = (await read_inverter_serial(validated_data)).strip()
if not serial_no:
raise InvalidInverterError("Inverter serial number was blank")

return validated_data, serial_no


async def read_inverter_serial(data: dict[str, Any]) -> str:
"""Validate user input by reading the inverter serial number."""
client = Client(data[CONF_HOST], 8899)
async with asyncio.timeout(10):
await client.connect()
await client.detect_plant()
try:
async with asyncio.timeout(10):
await client.connect()
await client.detect_plant()
serial_no: str = client.plant.inverter.serial_number
finally:
await client.close()

serial_no: str = client.plant.inverter.serial_number
return serial_no


Expand All @@ -41,16 +103,18 @@ async def async_step_user(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
)

errors = {}
errors: dict[str, str] = {}

try:
serial_no = await read_inverter_serial(user_input)
except Exception: # pylint: disable=broad-except
validated_input, serial_no = await _validate_input(user_input)
except Exception as err: # pylint: disable=broad-except
LOGGER.exception("Failed to validate inverter configuration")
errors["base"] = "cannot_connect"
errors["base"] = _map_validation_error(err)
else:
await self.async_set_unique_id(serial_no)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"Solar Inverter (S/N {serial_no})", data=user_input
title=f"Solar Inverter (S/N {serial_no})", data=validated_input
)

return self.async_show_form(
Expand All @@ -60,3 +124,51 @@ async def async_step_user(
),
errors=errors,
)

async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Allow users to update the configured inverter host in place."""
entry = self._get_reconfigure_entry()

if user_input is None:
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_RECONFIGURE_DATA_SCHEMA,
{CONF_HOST: entry.data.get(CONF_HOST, "")},
),
)

errors: dict[str, str] = {}

try:
validated_input, serial_no = await _validate_input(user_input)
except Exception as err: # pylint: disable=broad-except
LOGGER.exception("Failed to validate inverter reconfiguration")
errors["base"] = _map_validation_error(err)
else:
existing_entry = await self.async_set_unique_id(
serial_no, raise_on_progress=False
)
if existing_entry is not None and existing_entry.entry_id != entry.entry_id:
return self.async_abort(reason=ConfigFlowError.ALREADY_CONFIGURED)
if entry.unique_id is not None:
self._abort_if_unique_id_mismatch(
reason=ConfigFlowError.DIFFERENT_INVERTER
)

return self.async_update_reload_and_abort(
entry,
unique_id=serial_no,
data={**entry.data, **validated_input},
title=f"Solar Inverter (S/N {serial_no})",
)

return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_RECONFIGURE_DATA_SCHEMA, user_input
),
errors=errors,
)
29 changes: 28 additions & 1 deletion custom_components/givenergy_local/strings.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,40 @@
{
"config": {
"step": {
"user": {
"title": "Connect to inverter",
"data": {
"host": "Host"
},
"data_description": {
"host": "Enter the inverter hostname or IP address only, without http:// or a port."
}
},
"reconfigure": {
"title": "Update inverter connection",
"description": "Update the saved host for this inverter.",
"data": {
"host": "Host"
},
"data_description": {
"host": "Enter the new inverter hostname or IP address only, without http:// or a port."
}
},
"confirm": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
}
},
"error": {
"cannot_connect": "Failed to connect to the inverter.",
"invalid_host": "Enter a valid inverter hostname or IP address.",
"invalid_inverter": "The device responded, but it did not behave like a supported GivEnergy inverter."
},
"abort": {
"already_configured": "This inverter is already configured.",
"different_inverter": "The new host belongs to a different inverter. Use the original inverter host for this entry.",
"reconfigure_successful": "The inverter connection settings were updated.",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
}
}
}
}
27 changes: 23 additions & 4 deletions custom_components/givenergy_local/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,32 @@
"user": {
"title": "Connect to inverter",
"data": {
"host": "Host",
"num_batteries": "Number of batteries"
"host": "Host"
},
"data_description": {
"host": "Enter the inverter hostname or IP address only, without http:// or a port."
}
},
"reconfigure": {
"title": "Update inverter connection",
"description": "Update the saved host for this inverter.",
"data": {
"host": "Host"
},
"data_description": {
"host": "Enter the new inverter hostname or IP address only, without http:// or a port."
}
}
},
"error": {
"cannot_connect": "Failed to connect to the inverter."
"cannot_connect": "Failed to connect to the inverter.",
"invalid_host": "Enter a valid inverter hostname or IP address.",
"invalid_inverter": "The device responded, but it did not behave like a supported GivEnergy inverter."
},
"abort": {
"already_configured": "This inverter is already configured.",
"different_inverter": "The new host belongs to a different inverter. Use the original inverter host for this entry.",
"reconfigure_successful": "The inverter connection settings were updated."
}
}
}
}
Loading