Skip to content

Commit 57955fd

Browse files
committed
feat: Improve config flow validation, error handling, and reconfigure UX
- Validate and normalise the host field before connecting: strip whitespace, reject URLs containing :// or / - Map connection errors to granular user-visible error keys (invalid_host / cannot_connect / invalid_inverter) with translated messages in strings.json and translations/en.json - Move serial number read inside the try block so client.close() always runs on failure - Set a unique ID from the inverter serial number on first setup so HA can detect duplicate entries - Add async_step_reconfigure so users can update the inverter host in-place without removing and re-adding the integration
1 parent c109ccd commit 57955fd

3 files changed

Lines changed: 172 additions & 14 deletions

File tree

custom_components/givenergy_local/config_flow.py

Lines changed: 121 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
from __future__ import annotations
44

55
import asyncio
6+
from collections.abc import Mapping
7+
from enum import StrEnum
8+
import socket
69

710
from typing import Any
811

@@ -11,19 +14,78 @@
1114

1215
from .const import CONF_HOST, DOMAIN, LOGGER
1316
from .givenergy_modbus.client.client import Client
17+
from .givenergy_modbus.exceptions import CommunicationError
1418

1519
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
20+
STEP_RECONFIGURE_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
21+
22+
23+
class ConfigFlowError(StrEnum):
24+
"""User-visible config flow error keys."""
25+
26+
CANNOT_CONNECT = "cannot_connect"
27+
INVALID_HOST = "invalid_host"
28+
INVALID_INVERTER = "invalid_inverter"
29+
ALREADY_CONFIGURED = "already_configured"
30+
DIFFERENT_INVERTER = "different_inverter"
31+
32+
33+
class InvalidInverterError(Exception):
34+
"""Raised when a host responds, but not like a usable inverter."""
35+
36+
37+
def _normalise_host(host: str) -> str:
38+
"""Normalise and sanity-check host input before connecting."""
39+
normalised_host = host.strip()
40+
41+
if not normalised_host:
42+
raise ValueError("Host cannot be blank")
43+
44+
if "://" in normalised_host or "/" in normalised_host:
45+
raise ValueError("Host must be a hostname or IP address")
46+
47+
return normalised_host
48+
49+
50+
def _map_validation_error(err: Exception) -> ConfigFlowError:
51+
"""Map internal validation failures to translated config-flow errors."""
52+
if isinstance(err, (ValueError, socket.gaierror)):
53+
return ConfigFlowError.INVALID_HOST
54+
55+
if isinstance(
56+
err, (CommunicationError, TimeoutError, OSError, asyncio.TimeoutError)
57+
):
58+
return ConfigFlowError.CANNOT_CONNECT
59+
60+
if isinstance(err, (AttributeError, InvalidInverterError)):
61+
return ConfigFlowError.INVALID_INVERTER
62+
63+
return ConfigFlowError.CANNOT_CONNECT
64+
65+
66+
async def _validate_input(data: Mapping[str, Any]) -> tuple[dict[str, Any], str]:
67+
"""Validate and normalise user input, returning clean data and inverter serial."""
68+
validated_data = dict(data)
69+
validated_data[CONF_HOST] = _normalise_host(str(data[CONF_HOST]))
70+
71+
serial_no = (await read_inverter_serial(validated_data)).strip()
72+
if not serial_no:
73+
raise InvalidInverterError("Inverter serial number was blank")
74+
75+
return validated_data, serial_no
1676

1777

1878
async def read_inverter_serial(data: dict[str, Any]) -> str:
1979
"""Validate user input by reading the inverter serial number."""
2080
client = Client(data[CONF_HOST], 8899)
21-
async with asyncio.timeout(10):
22-
await client.connect()
23-
await client.detect_plant()
81+
try:
82+
async with asyncio.timeout(10):
83+
await client.connect()
84+
await client.detect_plant()
85+
serial_no: str = client.plant.inverter.serial_number
86+
finally:
2487
await client.close()
2588

26-
serial_no: str = client.plant.inverter.serial_number
2789
return serial_no
2890

2991

@@ -41,16 +103,18 @@ async def async_step_user(
41103
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
42104
)
43105

44-
errors = {}
106+
errors: dict[str, str] = {}
45107

46108
try:
47-
serial_no = await read_inverter_serial(user_input)
48-
except Exception: # pylint: disable=broad-except
109+
validated_input, serial_no = await _validate_input(user_input)
110+
except Exception as err: # pylint: disable=broad-except
49111
LOGGER.exception("Failed to validate inverter configuration")
50-
errors["base"] = "cannot_connect"
112+
errors["base"] = _map_validation_error(err)
51113
else:
114+
await self.async_set_unique_id(serial_no)
115+
self._abort_if_unique_id_configured()
52116
return self.async_create_entry(
53-
title=f"Solar Inverter (S/N {serial_no})", data=user_input
117+
title=f"Solar Inverter (S/N {serial_no})", data=validated_input
54118
)
55119

56120
return self.async_show_form(
@@ -60,3 +124,51 @@ async def async_step_user(
60124
),
61125
errors=errors,
62126
)
127+
128+
async def async_step_reconfigure(
129+
self, user_input: dict[str, Any] | None = None
130+
) -> ConfigFlowResult:
131+
"""Allow users to update the configured inverter host in place."""
132+
entry = self._get_reconfigure_entry()
133+
134+
if user_input is None:
135+
return self.async_show_form(
136+
step_id="reconfigure",
137+
data_schema=self.add_suggested_values_to_schema(
138+
STEP_RECONFIGURE_DATA_SCHEMA,
139+
{CONF_HOST: entry.data.get(CONF_HOST, "")},
140+
),
141+
)
142+
143+
errors: dict[str, str] = {}
144+
145+
try:
146+
validated_input, serial_no = await _validate_input(user_input)
147+
except Exception as err: # pylint: disable=broad-except
148+
LOGGER.exception("Failed to validate inverter reconfiguration")
149+
errors["base"] = _map_validation_error(err)
150+
else:
151+
existing_entry = await self.async_set_unique_id(
152+
serial_no, raise_on_progress=False
153+
)
154+
if existing_entry is not None and existing_entry.entry_id != entry.entry_id:
155+
return self.async_abort(reason=ConfigFlowError.ALREADY_CONFIGURED)
156+
if entry.unique_id is not None:
157+
self._abort_if_unique_id_mismatch(
158+
reason=ConfigFlowError.DIFFERENT_INVERTER
159+
)
160+
161+
return self.async_update_reload_and_abort(
162+
entry,
163+
unique_id=serial_no,
164+
data={**entry.data, **validated_input},
165+
title=f"Solar Inverter (S/N {serial_no})",
166+
)
167+
168+
return self.async_show_form(
169+
step_id="reconfigure",
170+
data_schema=self.add_suggested_values_to_schema(
171+
STEP_RECONFIGURE_DATA_SCHEMA, user_input
172+
),
173+
errors=errors,
174+
)
Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,40 @@
11
{
22
"config": {
33
"step": {
4+
"user": {
5+
"title": "Connect to inverter",
6+
"data": {
7+
"host": "Host"
8+
},
9+
"data_description": {
10+
"host": "Enter the inverter hostname or IP address only, without http:// or a port."
11+
}
12+
},
13+
"reconfigure": {
14+
"title": "Update inverter connection",
15+
"description": "Update the saved host for this inverter.",
16+
"data": {
17+
"host": "Host"
18+
},
19+
"data_description": {
20+
"host": "Enter the new inverter hostname or IP address only, without http:// or a port."
21+
}
22+
},
423
"confirm": {
524
"description": "[%key:common::config_flow::description::confirm_setup%]"
625
}
726
},
27+
"error": {
28+
"cannot_connect": "Failed to connect to the inverter.",
29+
"invalid_host": "Enter a valid inverter hostname or IP address.",
30+
"invalid_inverter": "The device responded, but it did not behave like a supported GivEnergy inverter."
31+
},
832
"abort": {
33+
"already_configured": "This inverter is already configured.",
34+
"different_inverter": "The new host belongs to a different inverter. Use the original inverter host for this entry.",
35+
"reconfigure_successful": "The inverter connection settings were updated.",
936
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
1037
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
1138
}
1239
}
13-
}
40+
}

custom_components/givenergy_local/translations/en.json

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,32 @@
44
"user": {
55
"title": "Connect to inverter",
66
"data": {
7-
"host": "Host",
8-
"num_batteries": "Number of batteries"
7+
"host": "Host"
8+
},
9+
"data_description": {
10+
"host": "Enter the inverter hostname or IP address only, without http:// or a port."
11+
}
12+
},
13+
"reconfigure": {
14+
"title": "Update inverter connection",
15+
"description": "Update the saved host for this inverter.",
16+
"data": {
17+
"host": "Host"
18+
},
19+
"data_description": {
20+
"host": "Enter the new inverter hostname or IP address only, without http:// or a port."
921
}
1022
}
1123
},
1224
"error": {
13-
"cannot_connect": "Failed to connect to the inverter."
25+
"cannot_connect": "Failed to connect to the inverter.",
26+
"invalid_host": "Enter a valid inverter hostname or IP address.",
27+
"invalid_inverter": "The device responded, but it did not behave like a supported GivEnergy inverter."
28+
},
29+
"abort": {
30+
"already_configured": "This inverter is already configured.",
31+
"different_inverter": "The new host belongs to a different inverter. Use the original inverter host for this entry.",
32+
"reconfigure_successful": "The inverter connection settings were updated."
1433
}
1534
}
16-
}
35+
}

0 commit comments

Comments
 (0)