Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
8513729
Add config step for auto or manual setup
WillCodeForCats Jul 28, 2024
aa041f9
Update translations
WillCodeForCats Jul 28, 2024
5f94afc
Merge branch 'config-inverter-id-list' into scan-for-inverters
WillCodeForCats Jul 28, 2024
29ed7c2
Merge branch 'config-inverter-id-list' into scan-for-inverters
WillCodeForCats Jul 29, 2024
c5cef61
Update config_flow.py
WillCodeForCats Jul 29, 2024
b927e91
Merge branch 'main' into scan-for-inverters
WillCodeForCats Aug 12, 2024
89d0e95
Merge branch 'main' into scan-for-inverters
WillCodeForCats Jun 9, 2025
2d8c86c
Merge branch 'main' into scan-for-inverters
WillCodeForCats Jul 15, 2025
450402a
Merge branch 'main' into scan-for-inverters
WillCodeForCats Dec 17, 2025
dec8e68
Merge branch 'main' into scan-for-inverters
WillCodeForCats Dec 17, 2025
f9a74d4
Format with ruff
WillCodeForCats Dec 17, 2025
eb9fbeb
Explicit name fast scan with no default
WillCodeForCats Dec 17, 2025
8234cda
Add scan request and response constants
WillCodeForCats Dec 17, 2025
6ef7a07
Update const.py
WillCodeForCats Dec 17, 2025
7efb6c7
Create scanner.py
WillCodeForCats Dec 24, 2025
5d6cce4
Add scanning setup option
WillCodeForCats Dec 24, 2025
a624821
Linter fixes
WillCodeForCats Dec 24, 2025
36b406f
Clean up docstrings
WillCodeForCats Dec 24, 2025
591c876
Remove old _sock references
WillCodeForCats Feb 5, 2026
f187454
Remove unused socket
WillCodeForCats Feb 5, 2026
8a404bd
Clean up unused code from test cycle
WillCodeForCats Feb 5, 2026
f2dbcc2
Bring back slow scan but make it an option
WillCodeForCats Feb 5, 2026
361fdb8
Add check_list method for checking manual setup option
WillCodeForCats Feb 5, 2026
cdf91dd
Make sure transaction is 0-65535
WillCodeForCats Feb 5, 2026
1b13a65
Fix flow in scan_device_id
WillCodeForCats Feb 5, 2026
99319ed
Refactor scanner calls in config flow
WillCodeForCats Feb 5, 2026
9648e0c
Update translations for scan step
WillCodeForCats Feb 5, 2026
6dadbee
Don't raise when exceeding scan attempts
WillCodeForCats Mar 14, 2026
b4a8ff8
Use self.NOT_FOUND instead of return 0
WillCodeForCats Mar 14, 2026
289ec47
Add scan_timeout parameter
WillCodeForCats Mar 14, 2026
fb05050
Format with ruff
WillCodeForCats Mar 14, 2026
e930a62
Merge scan-with-progress branch
WillCodeForCats Mar 16, 2026
2395349
Merge branch 'main' into scan-for-inverters
WillCodeForCats Mar 16, 2026
b2c541c
Check progress_callback before calling
WillCodeForCats Mar 16, 2026
b24dc31
Run inverter checks on manual setup step
WillCodeForCats Mar 16, 2026
78ea2d4
Bump version for pre-release
WillCodeForCats Apr 4, 2026
32f5f66
Missing disconnect and sleep for scanner check list
WillCodeForCats Apr 5, 2026
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
293 changes: 263 additions & 30 deletions custom_components/solaredge_modbus_multi/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,45 @@

from __future__ import annotations

import asyncio
import re
from typing import Any

import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry, OptionsFlow
from homeassistant.config_entries import ConfigEntry, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SCAN_INTERVAL
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError

from .const import (
DEFAULT_NAME,
DOMAIN,
SETUP_MANUAL,
SETUP_SCAN_FAST,
SETUP_SCAN_FULL,
SETUP_TYPE,
ConfDefaultFlag,
ConfDefaultInt,
ConfDefaultStr,
ConfName,
)
from .helpers import device_list_from_string, host_valid
from .scanner import SolarEdgeDeviceScanner


class ScanOtherDeviceError(HomeAssistantError):
"""Device IDs that responded but aren't SolarEdge inverters."""

pass


class ScanNoResponseError(HomeAssistantError):
"""Device IDs that didn't respond or timed out."""

pass


def generate_config_schema(step_id: str, user_input: dict[str, Any]) -> vol.Schema:
Expand Down Expand Up @@ -51,16 +69,190 @@ class SolaredgeModbusMultiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 2
MINOR_VERSION = 1

def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self._scan_task = None
self._scan_user_input = None
self._scan_task_result = None

@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
"""Create the options flow for SolarEdge Modbus Multi."""
return SolaredgeModbusMultiOptionsFlowHandler()

async def _async_update_progress_bar(self, scanned: int, total: int) -> None:
try:
progress = scanned / total if total > 0 else 0
self.async_update_progress(progress)
except asyncio.CancelledError:
pass

async def _async_scan_devices(self, user_input: dict[str, Any]) -> list[int]:
"""Scanner job for async_create_task"""
scanner = SolarEdgeDeviceScanner(
host=user_input[CONF_HOST],
port=user_input[CONF_PORT],
scan_retries=2,
scan_timeout=0.7,
)

try:
await scanner.connect()

if self.init_info[SETUP_TYPE] == SETUP_SCAN_FAST:
device_range = list(range(1, 33))
elif self.init_info[SETUP_TYPE] == SETUP_SCAN_FULL:
device_range = list(range(1, 248))
else:
raise HomeAssistantError(
f"Unknown setup type: {self.init_info[SETUP_TYPE]}"
)

scan_return = await scanner.scan_list(
device_range,
progress_callback=self._async_update_progress_bar,
)

except Exception as e:
scan_return = e

finally:
await scanner.disconnect()
await asyncio.sleep(1.0)

return scan_return

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial config flow step."""
) -> ConfigFlowResult:
"""Handle the initial step."""
return self.async_show_menu(
step_id="user",
menu_options=[SETUP_SCAN_FAST, SETUP_SCAN_FULL, SETUP_MANUAL],
)

async def async_step_scan_fast(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
self.init_info = {SETUP_TYPE: SETUP_SCAN_FAST}
return await self.async_step_scan_ask_host()

async def async_step_scan_full(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
self.init_info = {SETUP_TYPE: SETUP_SCAN_FULL}
return await self.async_step_scan_ask_host()

async def async_step_manual_list(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
self.init_info = {SETUP_TYPE: SETUP_MANUAL}
return await self.async_step_manual()

async def async_step_scan_ask_host(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the scan step - ask for host/port and perform scan with progress."""
errors = {}

# If we have a scan task running, show progress
if self._scan_task:
if not self._scan_task.done():
return self.async_show_progress(
step_id="scan_ask_host",
progress_action="scanning_for_inverters",
progress_task=self._scan_task,
)

self._scan_task_result = await self._scan_task
self._scan_task = None

return self.async_show_progress_done(next_step_id="scan_complete")

# Process user input and validate
if user_input is not None:
user_input[CONF_HOST] = user_input[CONF_HOST].lower()

if not host_valid(user_input[CONF_HOST]):
errors[CONF_HOST] = "invalid_host"
elif not 1 <= user_input[CONF_PORT] <= 65535:
errors[CONF_PORT] = "invalid_tcp_port"
else:
new_unique_id = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
await self.async_set_unique_id(new_unique_id)

self._abort_if_unique_id_configured()

# Store user input and create scan task
self._scan_user_input = user_input
self._scan_task = self.hass.async_create_task(
self._async_scan_devices(user_input),
f"Scan for SolarEdge inverters at {user_input[CONF_HOST]}:{user_input[CONF_PORT]}",
)

# Show progress immediately
return self.async_show_progress(
step_id="scan_ask_host",
progress_action="scanning_for_inverters",
progress_task=self._scan_task,
)

else:
user_input = {
CONF_NAME: DEFAULT_NAME,
CONF_HOST: "",
CONF_PORT: ConfDefaultInt.PORT,
ConfName.DEVICE_LIST: ConfDefaultStr.DEVICE_LIST,
}

return self.async_show_form(
step_id="scan_ask_host",
data_schema=vol.Schema(
{
vol.Optional(CONF_NAME, default=user_input[CONF_NAME]): cv.string,
vol.Required(CONF_HOST, default=user_input[CONF_HOST]): cv.string,
vol.Required(CONF_PORT, default=user_input[CONF_PORT]): vol.Coerce(
int
),
},
),
errors=errors,
)

async def async_step_scan_complete(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Complete the scan and create the config entry."""

try:
if isinstance(self._scan_task_result, Exception):
raise self._scan_task_result

if not self._scan_task_result:
raise HomeAssistantError(
"No SolarEdge devices were detected at "
f"{self._scan_user_input[CONF_HOST]}:{self._scan_user_input[CONF_PORT]}"
)

self._scan_user_input[ConfName.DEVICE_LIST] = self._scan_task_result

if self._scan_user_input is None:
raise AbortFlow("No scan data available")

return self.async_create_entry(
title=self._scan_user_input[CONF_NAME],
data=self._scan_user_input,
)

except Exception as e:
raise AbortFlow(f"Scan failed: {e}")

async def async_step_manual(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the manual config flow step."""
errors = {}

if user_input is not None:
Expand All @@ -69,33 +261,62 @@ async def async_step_user(
r"\s+", "", user_input[ConfName.DEVICE_LIST], flags=re.UNICODE
)

scanner = SolarEdgeDeviceScanner(
host=user_input[CONF_HOST],
port=user_input[CONF_PORT],
scan_retries=3,
scan_timeout=0.5,
)

try:
inverter_count = len(
device_list_from_string(user_input[ConfName.DEVICE_LIST])
)
except HomeAssistantError as e:
errors[ConfName.DEVICE_LIST] = f"{e}"
device_list = device_list_from_string(user_input[ConfName.DEVICE_LIST])

else:
if not host_valid(user_input[CONF_HOST]):
errors[CONF_HOST] = "invalid_host"
elif not 1 <= user_input[CONF_PORT] <= 65535:
errors[CONF_PORT] = "invalid_tcp_port"
elif not 1 <= inverter_count <= 32:
errors[ConfName.DEVICE_LIST] = "invalid_inverter_count"
else:
new_unique_id = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
await self.async_set_unique_id(new_unique_id)
await scanner.connect()

self._abort_if_unique_id_configured()
scan_return = await scanner.check_list(device_list)

user_input[ConfName.DEVICE_LIST] = device_list_from_string(
user_input[ConfName.DEVICE_LIST]
if scan_return["other_devices"]:
raise ScanOtherDeviceError(
f"Invalid devices found at ID(s): {scan_return['other_devices']}"
)

return self.async_create_entry(
title=user_input[CONF_NAME], data=user_input
if scan_return["no_response"]:
raise ScanNoResponseError(
f"No response from ID(s): {scan_return['no_response']}"
)

if not scan_return["inverters"]:
raise HomeAssistantError("No inverter devices found in ID list.")

inverter_count = len(scan_return["inverters"])
user_input[ConfName.DEVICE_LIST] = scan_return["inverters"]

except (ScanOtherDeviceError, ScanNoResponseError) as e:
errors[ConfName.DEVICE_LIST] = f"{e}"

except HomeAssistantError as e:
errors[CONF_HOST] = f"{e}"

finally:
await scanner.disconnect()
await asyncio.sleep(1.0)

if not host_valid(user_input[CONF_HOST]):
errors[CONF_HOST] = "invalid_host"
elif not 1 <= user_input[CONF_PORT] <= 65535:
errors[CONF_PORT] = "invalid_tcp_port"
elif not 1 <= inverter_count <= 32:
errors[ConfName.DEVICE_LIST] = "invalid_inverter_count"
else:
new_unique_id = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
await self.async_set_unique_id(new_unique_id)

self._abort_if_unique_id_configured()

return self.async_create_entry(
title=user_input[CONF_NAME], data=user_input
)

else:
user_input = {
CONF_NAME: DEFAULT_NAME,
Expand All @@ -105,14 +326,26 @@ async def async_step_user(
}

return self.async_show_form(
step_id="user",
data_schema=generate_config_schema("user", user_input),
step_id="manual",
data_schema=vol.Schema(
{
vol.Optional(CONF_NAME, default=user_input[CONF_NAME]): cv.string,
vol.Required(CONF_HOST, default=user_input[CONF_HOST]): cv.string,
vol.Required(CONF_PORT, default=user_input[CONF_PORT]): vol.Coerce(
int
),
vol.Required(
f"{ConfName.DEVICE_LIST}",
default=user_input[ConfName.DEVICE_LIST],
): cv.string,
},
),
errors=errors,
)

async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
) -> ConfigFlowResult:
"""Handle the reconfigure flow step."""
errors = {}
config_entry = self.hass.config_entries.async_get_entry(
Expand Down Expand Up @@ -185,7 +418,7 @@ class SolaredgeModbusMultiOptionsFlowHandler(OptionsFlow):

async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
) -> ConfigFlowResult:
"""Handle the initial options flow step."""
errors = {}

Expand Down Expand Up @@ -274,7 +507,7 @@ async def async_step_init(

async def async_step_battery_options(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
) -> ConfigFlowResult:
"""Battery Options"""
errors = {}

Expand Down Expand Up @@ -331,7 +564,7 @@ async def async_step_battery_options(

async def async_step_adv_pwr_ctl(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
) -> ConfigFlowResult:
"""Power Control Options"""
errors = {}

Expand Down
7 changes: 7 additions & 0 deletions custom_components/solaredge_modbus_multi/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@
DOMAIN = "solaredge_modbus_multi"
DEFAULT_NAME = "SolarEdge"

SETUP_TYPE = "setup_type"
SETUP_SCAN_FAST = "scan_fast" # Scan IDs 1-32
SETUP_SCAN_FULL = "scan_full" # Scan IDs 1-247
SETUP_MANUAL = "manual_list"

SCAN_RETRIES = 3

# raise a startup exception if pymodbus version is less than this
PYMODBUS_REQUIRED_VERSION = "3.8.3"

Expand Down
Loading
Loading