33from __future__ import annotations
44
55import asyncio
6+ from collections .abc import Mapping
7+ from enum import StrEnum
8+ import socket
69
710from typing import Any
811
1114
1215from .const import CONF_HOST , DOMAIN , LOGGER
1316from .givenergy_modbus .client .client import Client
17+ from .givenergy_modbus .exceptions import CommunicationError
1418
1519STEP_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
1878async 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+ )
0 commit comments