Skip to content

Commit 77e4573

Browse files
authored
Merge pull request #30 from fboundy/patch
Make inverter serial optional for hybrid Modbus setup
2 parents a071c37 + 0d0f929 commit 77e4573

5 files changed

Lines changed: 121 additions & 17 deletions

File tree

‎.github/FUNDING.yml‎

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
# These are supported funding model platforms
22

3-
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4-
patreon: # Replace with a single Patreon username
5-
open_collective: # Replace with a single Open Collective username
6-
ko_fi: https://ko-fi.com/fboundy#
7-
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8-
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9-
liberapay: # Replace with a single Liberapay username
10-
issuehunt: # Replace with a single IssueHunt username
11-
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12-
polar: # Replace with a single Polar username
13-
buy_me_a_coffee: pho3nix90 # Replace with a single Buy Me a Coffee username
14-
thanks_dev: # Replace with a single thanks.dev username
15-
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
3+
# github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4+
# patreon: # Replace with a single Patreon username
5+
# open_collective: # Replace with a single Open Collective username
6+
ko_fi: fboundy
7+
# tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8+
# community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9+
# liberapay: # Replace with a single Liberapay username
10+
# issuehunt: # Replace with a single IssueHunt username
11+
# lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12+
# polar: # Replace with a single Polar username
13+
# buy_me_a_coffee: pho3nix90 # Replace with a single Buy Me a Coffee username
14+
# thanks_dev: # Replace with a single thanks.dev username
15+
# custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

‎.gitignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,7 @@ scripts/soliscloud_detail_dump.json
7373

7474
# Working handoff notes (contain internal server details and real inverter serial; repo is public)
7575
WORKING_NOTES.md
76+
77+
# Source docs
78+
docs/api/
79+

‎README.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,11 @@ Home Assistant only allows one `iot_class` value in the manifest. SolisConnect i
117117
- The SolisCloud API is rate-limited by the integration. Avoid running another cloud integration against the same key if you are seeing slow or failed cloud updates.
118118
- If older debug logs captured SolisCloud credentials before redaction was added, rotate the SolisCloud key secret and clear old logs.
119119

120-
**Inverter Serial**: (Required)
120+
**Inverter Serial**: (Optional on hybrid Modbus models and SolisCloud; required otherwise)
121121

122-
- Enter your inverter's serial number. This is now **mandatory** for generating unique entity IDs and ensuring configuration stability.
123-
- On Modbus hybrid inverters, once connectivity is confirmed the integration reads the serial directly from the device and silently corrects a mistyped value (grid/string models don't expose this register, so the typed value is used as-is). On SolisCloud, the serial is optional and auto-resolved when your plant has exactly one inverter.
122+
- Used to generate unique entity IDs and ensure configuration stability — every entry needs one, but you don't always have to type it.
123+
- On Modbus **hybrid** inverters, you can leave this blank: once connectivity is confirmed, the integration reads the serial directly from the device and uses it (silently correcting a mistyped value too, if you did type one). **Grid/string** models don't expose this register, so you still need to type it there — if you leave it blank on a grid/string model, the form will ask again.
124+
- On SolisCloud, the serial is optional and auto-resolved when your plant has exactly one inverter; you only need to type it to disambiguate a plant with multiple inverters.
124125

125126
**Poll Interval**:
126127

‎custom_components/solisconnect/config_flow.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,11 @@
7575
# Base schema with common fields (for both TCP and Serial)
7676
BASE_CONFIG_SCHEMA = {
7777
vol.Required(CONF_CONNECTION_TYPE, default=CONN_TYPE_TCP): vol.In(CONNECTION_TYPES),
78-
vol.Required(CONF_INVERTER_SERIAL): str,
78+
# Optional: hybrid inverters have their serial read from the device (registers
79+
# 33004-33019) once connectivity is confirmed, so it doesn't need to be typed. Grid/
80+
# string models don't expose that register range, so _create_entry_from_input (and the
81+
# dual-mode Modbus step) re-check after validation and ask again if it's still blank.
82+
vol.Optional(CONF_INVERTER_SERIAL, default=""): str,
7983
vol.Required("slave", default=1): int,
8084
vol.Optional("poll_interval_fast", default=10): vol.All(int, vol.Range(min=10)),
8185
vol.Optional("poll_interval_normal", default=15): vol.All(int, vol.Range(min=15)),
@@ -538,6 +542,17 @@ async def _create_entry_from_input(self, data):
538542

539543
return self.async_show_form(step_id="config", data_schema=vol.Schema(schema_dict), errors=errors)
540544

545+
# 2b. The serial field is optional because hybrid models auto-detect it during
546+
# validation above; grid/string models don't expose that register range, so if it's
547+
# still blank at this point there's no way to identify the device — ask for it.
548+
if not (data.get(CONF_INVERTER_SERIAL) or "").strip():
549+
errors["base"] = "Could not detect the inverter serial automatically; please enter it manually."
550+
if data.get(CONF_CONNECTION_TYPE) == CONN_TYPE_TCP:
551+
schema_dict = TCP_CONFIG_SCHEMA
552+
else:
553+
schema_dict = SERIAL_CONFIG_SCHEMA
554+
return self.async_show_form(step_id="config", data_schema=self.add_suggested_values_to_schema(vol.Schema(schema_dict), data), errors=errors)
555+
541556
# 3. Check for Duplicates
542557
if CONF_INVERTER_SERIAL in data:
543558
await self.async_set_unique_id(data[CONF_INVERTER_SERIAL])

‎tests/test_config_flow.py‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,90 @@ async def fake_read_input_register(register, count):
253253
assert 33002 in registers_read # HMI-version detection is not gated by inverter type
254254

255255

256+
@pytest.mark.asyncio
257+
async def test_flow_user_blank_serial_auto_detected_from_device(hass: HomeAssistant):
258+
"""The serial field is optional: a hybrid model can be set up without typing it at all."""
259+
device_serial_registers = _encode_serial_registers("1234567890123456")
260+
261+
async def fake_read_input_register(register, count):
262+
if register == 33004:
263+
return device_serial_registers
264+
return [1]
265+
266+
with (
267+
patch("custom_components.solisconnect.modbus_controller.ModbusController.connect", return_value=True),
268+
patch(
269+
"custom_components.solisconnect.modbus_controller.ModbusController.async_read_input_register",
270+
side_effect=fake_read_input_register,
271+
),
272+
patch("custom_components.solisconnect.async_setup_entry", return_value=True),
273+
):
274+
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER})
275+
result = await hass.config_entries.flow.async_configure(result["flow_id"], user_input={"connection_type": CONN_TYPE_TCP})
276+
277+
config_input = {
278+
"host": "1.2.3.4",
279+
"port": 502,
280+
"slave": 1,
281+
"model": "S6-EH1P",
282+
"connection": "S2_WL_ST",
283+
"has_v2": True,
284+
"has_pv": True,
285+
"has_ac_coupling": False,
286+
"has_parallel": False,
287+
"has_battery": True,
288+
"has_hv_battery": False,
289+
"has_generator": False,
290+
# inverter_serial omitted entirely
291+
}
292+
result = await hass.config_entries.flow.async_configure(result["flow_id"], user_input=config_input)
293+
294+
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
295+
assert result["data"]["inverter_serial"] == "1234567890123456"
296+
297+
298+
@pytest.mark.asyncio
299+
async def test_flow_user_blank_serial_without_detection_shows_error(hass: HomeAssistant):
300+
"""A grid model with no typed serial has no way to auto-detect one; the form must ask again."""
301+
302+
async def fake_read_input_register(register, count):
303+
if register == 33004:
304+
return None # grid models don't expose this range anyway; belt-and-braces
305+
return [1]
306+
307+
with (
308+
patch("custom_components.solisconnect.modbus_controller.ModbusController.connect", return_value=True),
309+
patch(
310+
"custom_components.solisconnect.modbus_controller.ModbusController.async_read_input_register",
311+
side_effect=fake_read_input_register,
312+
),
313+
patch("custom_components.solisconnect.async_setup_entry", return_value=True),
314+
):
315+
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER})
316+
result = await hass.config_entries.flow.async_configure(result["flow_id"], user_input={"connection_type": CONN_TYPE_TCP})
317+
318+
config_input = {
319+
"host": "1.2.3.4",
320+
"port": 502,
321+
"slave": 1,
322+
"model": "S6-GR1P",
323+
"connection": "S2_WL_ST",
324+
"has_v2": True,
325+
"has_pv": True,
326+
"has_ac_coupling": False,
327+
"has_parallel": False,
328+
"has_battery": True,
329+
"has_hv_battery": False,
330+
"has_generator": False,
331+
# inverter_serial omitted; grid model can't auto-detect it
332+
}
333+
result = await hass.config_entries.flow.async_configure(result["flow_id"], user_input=config_input)
334+
335+
assert result["type"] == data_entry_flow.FlowResultType.FORM
336+
assert result["step_id"] == "config"
337+
assert "manually" in result["errors"]["base"]
338+
339+
256340
@pytest.mark.asyncio
257341
async def test_flow_user_connection_error(hass: HomeAssistant):
258342
"""Test user initialized flow with connection error."""

0 commit comments

Comments
 (0)