Skip to content

Commit d697950

Browse files
authored
Merge pull request #307 from Pho3niX90/feature/december_v4
Require inverter serial, unify device identifiers, add reconfigure flow
2 parents 95e2916 + fa142d1 commit d697950

32 files changed

Lines changed: 1018 additions & 350 deletions

custom_components/solis_modbus/__init__.py

Lines changed: 19 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,13 @@
1313
DOMAIN, CONTROLLER, TIME_ENTITIES,
1414
CONN_TYPE_TCP, CONN_TYPE_SERIAL, CONF_SERIAL_PORT,
1515
CONF_BAUDRATE, CONF_BYTESIZE, CONF_PARITY, CONF_STOPBITS,
16-
CONF_CONNECTION_TYPE, DEFAULT_BAUDRATE, DEFAULT_BYTESIZE,
16+
CONF_CONNECTION_TYPE, CONF_INVERTER_SERIAL, DEFAULT_BAUDRATE, DEFAULT_BYTESIZE,
1717
DEFAULT_PARITY, DEFAULT_STOPBITS
1818
)
1919
from .data.enums import InverterFeature
2020
from .data.solis_config import SOLIS_INVERTERS, InverterConfig, InverterType
2121
from .data_retrieval import DataRetrieval
2222
from .helpers import get_controller, set_controller
23-
from homeassistant.helpers import entity_registry as er
2423
from .modbus_controller import ModbusController
2524
from .sensors.solis_base_sensor import SolisSensorGroup, SolisBaseSensor
2625
from .sensors.solis_derived_sensor import SolisDerivedSensor
@@ -43,24 +42,6 @@
4342
}
4443
)
4544

46-
async def async_migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry, host: str, port: int):
47-
"""Migrate legacy unique_ids (missing port) to new format."""
48-
if port == 502:
49-
return
50-
51-
_LOGGER.debug(f"Checking for unique_id migration for host {host}, port {port}")
52-
registry = er.async_get(hass)
53-
entries = er.async_entries_for_config_entry(registry, entry.entry_id)
54-
55-
old_prefix = f"{DOMAIN}_{host}_"
56-
new_prefix = f"{DOMAIN}_{host}_{port}_"
57-
58-
for entity in entries:
59-
if entity.unique_id.startswith(old_prefix) and not entity.unique_id.startswith(new_prefix):
60-
new_unique_id = entity.unique_id.replace(old_prefix, new_prefix, 1)
61-
_LOGGER.warning(f"Migrating entity {entity.entity_id} unique_id from {entity.unique_id} to {new_unique_id}")
62-
registry.async_update_entity(entity.entity_id, new_unique_id=new_unique_id)
63-
6445

6546
async def async_setup(hass: HomeAssistant, entry: ConfigEntry):
6647
"""Set up the Modbus integration."""
@@ -129,14 +110,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
129110
# Merge data and options (options take priority)
130111
config = {**entry.data, **entry.options}
131112
slave = config.get("slave", 1)
113+
inverter_serial = config.get(CONF_INVERTER_SERIAL)
114+
115+
if not inverter_serial:
116+
hass.components.persistent_notification.async_create(
117+
"Solis Modbus: Inverter Serial is missing. Please reconfigure the integration.",
118+
title="Solis Modbus Configuration Issue",
119+
notification_id="solis_modbus_missing_serial",
120+
)
121+
raise ConfigEntryError("Inverter Serial is missing")
132122

133123
# Determine connection type (default to TCP for backwards compatibility with old configs)
134124
connection_type = config.get(CONF_CONNECTION_TYPE, CONN_TYPE_TCP if "host" in config else CONN_TYPE_SERIAL)
135125

136126
# Get connection-specific parameters
127+
host = config.get("host")
128+
port = config.get("port", 502)
129+
137130
if connection_type == CONN_TYPE_TCP:
138-
host = config.get("host")
139-
port = config.get("port", 502)
140131
connection_id = f"{host}:{port}"
141132
else: # Serial
142133
serial_port = config.get(CONF_SERIAL_PORT, "/dev/ttyUSB0")
@@ -163,19 +154,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
163154
hass.data[DOMAIN][entry.entry_id] = entry
164155
_LOGGER.info(f"Loaded Solis Modbus Integration ({connection_type}) with Model: {config.get('model')}")
165156

166-
# Migrate unique_ids if needed (TCP only)
167-
if connection_type == CONN_TYPE_TCP:
168-
await async_migrate_unique_ids(hass, entry, host, port)
169-
170157
poll_interval_fast = config.get("poll_interval_fast", 5)
171158
poll_interval_normal = config.get("poll_interval_normal", 15)
172159
poll_interval_slow = config.get("poll_interval_slow", 30)
173160
inverter_model = config.get("model")
174-
identification = config.get("identification", None)
175161

176162
if inverter_model is None:
177163
old_type = config.get("type", "hybrid")
178-
inverter_model = "S6-EH3P" if old_type == "hybrid" else ("WAVESHARE" if old_type == "hybrid-waveshare" else "S6-GR1P")
164+
inverter_model = "S6-EH3P" if old_type == "hybrid" else (
165+
"WAVESHARE" if old_type == "hybrid-waveshare" else "S6-GR1P")
179166

180167
inverter_config: InverterConfig = next(
181168
(inv for inv in SOLIS_INVERTERS if inv.model == inverter_model), None
@@ -213,12 +200,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
213200
controller_params = {
214201
"hass": hass,
215202
"device_id": slave,
216-
"identification": identification,
217203
"fast_poll": poll_interval_fast,
218204
"normal_poll": poll_interval_normal,
219205
"slow_poll": poll_interval_slow,
220206
"inverter_config": inverter_config,
221-
"connection_type": connection_type
207+
"connection_type": connection_type,
208+
"serial_number": inverter_serial
222209
}
223210

224211
if connection_type == CONN_TYPE_TCP:
@@ -245,7 +232,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
245232
continue # Skip this group
246233

247234
# If it passes the check, add to sensor groups
248-
controller._sensor_groups.append(SolisSensorGroup(hass=hass, definition=group, controller=controller, identification=identification))
235+
controller._sensor_groups.append(SolisSensorGroup(hass=hass, definition=group, controller=controller))
249236

250237
controller._derived_sensors = [
251238
SolisBaseSensor(
@@ -261,13 +248,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
261248
hidden=entity.get("hidden", False),
262249
multiplier=entity.get("multiplier", 1),
263250
category=entity.get("category", None),
264-
identification=entity.get("identification", None),
265-
unique_id=f"{DOMAIN}_{identification}_{entity['unique']}" if identification else f"{DOMAIN}_{connection_id.replace(':', '_').replace('/', '_')}{f'_{slave}' if slave != 1 else ''}_{entity['unique']}"
251+
unique_id=f"{DOMAIN}_{controller.serial_number}_{entity['unique']}" if controller.serial_number else f"{DOMAIN}_{connection_id.replace(':', '_').replace('/', '_')}{f'_{slave}' if slave != 1 else ''}_{entity['unique']}"
266252
)
267253
for entity in sensors_derived
268254
]
269255

270-
271256
set_controller(hass, controller)
272257

273258
_LOGGER.debug(f"Config entry setup for {connection_type} connection: {connection_id}, slave {slave}")

custom_components/solis_modbus/config_flow.py

Lines changed: 74 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
1-
import voluptuous as vol
1+
import asyncio
22
import logging
3+
4+
import voluptuous as vol
35
from homeassistant import config_entries
46
from homeassistant.config_entries import OptionsFlowWithConfigEntry
57

68
from . import ModbusController
79
from .const import (
810
DOMAIN, CONN_TYPE_TCP, CONN_TYPE_SERIAL, CONF_SERIAL_PORT,
911
CONF_BAUDRATE, CONF_BYTESIZE, CONF_PARITY, CONF_STOPBITS,
10-
CONF_CONNECTION_TYPE, DEFAULT_BAUDRATE, DEFAULT_BYTESIZE,
12+
CONF_CONNECTION_TYPE, CONF_INVERTER_SERIAL, DEFAULT_BAUDRATE, DEFAULT_BYTESIZE,
1113
DEFAULT_PARITY, DEFAULT_STOPBITS
1214
)
1315
from .data.enums import InverterType
1416
from .data.solis_config import SOLIS_INVERTERS, InverterConfig, CONNECTION_METHOD
15-
import re
1617

1718
_LOGGER = logging.getLogger(__name__)
1819

@@ -34,13 +35,13 @@
3435

3536
# Base schema with common fields (for both TCP and Serial)
3637
BASE_CONFIG_SCHEMA = {
37-
vol.Required(CONF_CONNECTION_TYPE, default=CONN_TYPE_SERIAL): vol.In(CONNECTION_TYPES),
38+
vol.Required(CONF_CONNECTION_TYPE, default=CONN_TYPE_TCP): vol.In(CONNECTION_TYPES),
39+
vol.Required(CONF_INVERTER_SERIAL): str,
3840
vol.Required("slave", default=1): int,
3941
vol.Optional("poll_interval_fast", default=10): vol.All(int, vol.Range(min=10)),
4042
vol.Optional("poll_interval_normal", default=15): vol.All(int, vol.Range(min=15)),
4143
vol.Optional("poll_interval_slow", default=30): vol.All(int, vol.Range(min=30)),
4244
vol.Required("model", default=list(SOLIS_MODELS.keys())[0]): vol.In(SOLIS_MODELS),
43-
vol.Optional("identification", default=""): str,
4445
# Boolean options (Yes/No toggle)
4546
vol.Required("has_v2", default=True): vol.Coerce(bool),
4647
vol.Required("has_pv", default=True): vol.Coerce(bool),
@@ -101,15 +102,6 @@
101102
)
102103

103104

104-
def clean_identification(iden: str | None) -> str | None:
105-
if not iden or not iden.strip():
106-
return None
107-
# Replace spaces and disallowed characters with underscores
108-
iden = iden.strip().lower()
109-
iden = re.sub(r"[^a-z0-9_]", "_", iden)
110-
return re.sub(r"_+", "_", iden).strip("_")
111-
112-
113105
class ModbusConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
114106
"""Modbus configuration flow."""
115107

@@ -137,7 +129,7 @@ async def async_step_user(self, user_input=None):
137129
return self.async_show_form(
138130
step_id="user",
139131
data_schema=vol.Schema({
140-
vol.Required(CONF_CONNECTION_TYPE, default=CONN_TYPE_SERIAL): vol.In(CONNECTION_TYPES),
132+
vol.Required(CONF_CONNECTION_TYPE, default=CONN_TYPE_TCP): vol.In(CONNECTION_TYPES),
141133
}),
142134
errors=errors
143135
)
@@ -155,12 +147,12 @@ async def async_step_config(self, user_input=None):
155147
if self._connection_type == CONN_TYPE_TCP:
156148
# Create TCP schema without connection_type field
157149
schema_dict = {k: v for k, v in TCP_CONFIG_SCHEMA.items()
158-
if not (hasattr(k, 'schema') and k.schema == CONF_CONNECTION_TYPE)}
150+
if not (hasattr(k, 'schema') and k.schema == CONF_CONNECTION_TYPE)}
159151
schema = vol.Schema(schema_dict)
160152
else:
161153
# Create Serial schema without connection_type field
162154
schema_dict = {k: v for k, v in SERIAL_CONFIG_SCHEMA.items()
163-
if not (hasattr(k, 'schema') and k.schema == CONF_CONNECTION_TYPE)}
155+
if not (hasattr(k, 'schema') and k.schema == CONF_CONNECTION_TYPE)}
164156
schema = vol.Schema(schema_dict)
165157

166158
return self.async_show_form(
@@ -169,6 +161,50 @@ async def async_step_config(self, user_input=None):
169161
errors=errors
170162
)
171163

164+
async def async_step_reconfigure(self, user_input=None):
165+
"""Handle reconfiguration."""
166+
errors = {}
167+
entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
168+
169+
if user_input is not None:
170+
data = {**entry.data, **user_input}
171+
172+
if await self._validate_config(data):
173+
return self.async_update_reload_and_abort(
174+
entry, data=data
175+
)
176+
177+
errors["base"] = "Cannot connect to Modbus device. Please check your configuration."
178+
179+
# Show config form with existing values pre-filled
180+
# Determine schema based on existing connection type
181+
conn_type = entry.data.get(CONF_CONNECTION_TYPE, CONN_TYPE_TCP)
182+
183+
if conn_type == CONN_TYPE_TCP:
184+
schema_dict = TCP_CONFIG_SCHEMA.copy()
185+
else:
186+
schema_dict = SERIAL_CONFIG_SCHEMA.copy()
187+
188+
# Re-create schema with defaults from entry data
189+
new_schema = {}
190+
for key, value in schema_dict.items():
191+
if key in entry.data:
192+
# If key exists in data, use it as default
193+
new_schema[key] = vol.Required(key, default=entry.data[key]) if isinstance(value,
194+
vol.Required) else vol.Optional(
195+
key, default=entry.data[key])
196+
elif key == CONF_INVERTER_SERIAL and CONF_INVERTER_SERIAL not in entry.data:
197+
# If serial is missing (the problem we are solving), make it required without default (or empty string)
198+
new_schema[key] = vol.Required(key)
199+
else:
200+
new_schema[key] = value
201+
202+
return self.async_show_form(
203+
step_id="reconfigure",
204+
data_schema=vol.Schema(new_schema),
205+
errors=errors
206+
)
207+
172208
async def _create_entry_from_input(self, user_input):
173209
"""Create config entry from validated input."""
174210
conn_type = user_input.get(CONF_CONNECTION_TYPE, CONN_TYPE_SERIAL)
@@ -181,10 +217,10 @@ async def _create_entry_from_input(self, user_input):
181217
# Multi-step flow - return to config step without connection_type field
182218
if conn_type == CONN_TYPE_TCP:
183219
schema_dict = {k: v for k, v in TCP_CONFIG_SCHEMA.items()
184-
if not (hasattr(k, 'schema') and k.schema == CONF_CONNECTION_TYPE)}
220+
if not (hasattr(k, 'schema') and k.schema == CONF_CONNECTION_TYPE)}
185221
else:
186222
schema_dict = {k: v for k, v in SERIAL_CONFIG_SCHEMA.items()
187-
if not (hasattr(k, 'schema') and k.schema == CONF_CONNECTION_TYPE)}
223+
if not (hasattr(k, 'schema') and k.schema == CONF_CONNECTION_TYPE)}
188224

189225
return self.async_show_form(
190226
step_id="config",
@@ -228,7 +264,6 @@ async def _validate_config(self, user_input):
228264
controller_params = {
229265
"hass": self.hass,
230266
"device_id": user_input.get("slave", 1),
231-
"identification": clean_identification(user_input.get("identification", None)),
232267
"fast_poll": user_input.get("poll_interval_fast", 10),
233268
"normal_poll": user_input.get("poll_interval_normal", 15),
234269
"slow_poll": user_input.get("poll_interval_slow", 15),
@@ -248,19 +283,25 @@ async def _validate_config(self, user_input):
248283

249284
modbus_controller = ModbusController(**controller_params)
250285

251-
try:
252-
if not await modbus_controller.connect():
253-
raise ConnectionError("Failed to connect")
254-
if inverter_config.type in [InverterType.GRID, InverterType.STRING]:
255-
await modbus_controller.async_read_input_register(3041, 1)
256-
else:
257-
await modbus_controller.async_read_input_register(35000, 1)
258-
return True
259-
except Exception as e:
260-
_LOGGER.error(f"Connection failed: {str(e)}")
261-
return False
262-
finally:
263-
modbus_controller.close_connection()
286+
for attempt in range(5):
287+
try:
288+
if not await modbus_controller.connect():
289+
raise ConnectionError("Failed to connect")
290+
if inverter_config.type in [InverterType.GRID, InverterType.STRING]:
291+
await modbus_controller.async_read_input_register(3041, 1)
292+
else:
293+
await modbus_controller.async_read_input_register(35000, 1)
294+
295+
return True
296+
except Exception as e:
297+
_LOGGER.warning(f"Connection failed attempt {attempt + 1}/5: {str(e)}")
298+
if attempt < 4:
299+
await asyncio.sleep(1)
300+
finally:
301+
modbus_controller.close_connection()
302+
303+
_LOGGER.error(f"Connection failed after 5 attempts: {str(controller_params)}")
304+
return False
264305

265306
def _get_user_schema(self, user_input=None):
266307
"""Return the appropriate schema based on connection type selection."""

custom_components/solis_modbus/const.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@
2525
CONF_PARITY = "parity"
2626
CONF_STOPBITS = "stopbits"
2727
CONF_CONNECTION_TYPE = "connection_type"
28+
CONF_INVERTER_SERIAL = "inverter_serial"
2829

2930
# Default serial values (standard for Solis inverters)
3031
DEFAULT_BAUDRATE = 9600
3132
DEFAULT_BYTESIZE = 8
3233
DEFAULT_PARITY = "N"
33-
DEFAULT_STOPBITS = 1
34+
DEFAULT_STOPBITS = 1

custom_components/solis_modbus/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@
1010
"issue_tracker": "https://github.com/Pho3niX90/solis_modbus/issues",
1111
"quality_scale": "silver",
1212
"requirements": ["pymodbus>=3.11.1"],
13-
"version": "3.4.1"
13+
"version": "3.4.12"
1414
}

0 commit comments

Comments
 (0)