diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index cfa95162eed..523c6a67433 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -56,6 +56,7 @@ from .utils import ( get_block_device_sleep_period, get_coap_context, get_device_entry_gen, + get_http_port, get_rpc_device_wakeup_period, get_ws_context, ) @@ -249,6 +250,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> boo entry.data.get(CONF_USERNAME), entry.data.get(CONF_PASSWORD), device_mac=entry.unique_id, + port=get_http_port(entry.data), ) ws_context = await get_ws_context(hass) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index ca56552d125..ca1190de708 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -7,8 +7,9 @@ from typing import Any, Final from aioshelly.block_device import BlockDevice from aioshelly.common import ConnectionOptions, get_info -from aioshelly.const import BLOCK_GENERATIONS, RPC_GENERATIONS +from aioshelly.const import BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS from aioshelly.exceptions import ( + CustomPortNotSupported, DeviceConnectionError, FirmwareUnsupported, InvalidAuthError, @@ -23,7 +24,7 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig @@ -42,6 +43,7 @@ from .utils import ( get_block_device_sleep_period, get_coap_context, get_device_entry_gen, + get_http_port, get_info_auth, get_info_gen, get_model_name, @@ -50,7 +52,12 @@ from .utils import ( mac_address_from_name, ) -HOST_SCHEMA: Final = vol.Schema({vol.Required(CONF_HOST): str}) +CONFIG_SCHEMA: Final = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_HTTP_PORT): vol.Coerce(int), + } +) BLE_SCANNER_OPTIONS = [ @@ -65,14 +72,20 @@ INTERNAL_WIFI_AP_IP = "192.168.33.1" async def validate_input( hass: HomeAssistant, host: str, + port: int, info: dict[str, Any], data: dict[str, Any], ) -> dict[str, Any]: """Validate the user input allows us to connect. - Data has the keys from HOST_SCHEMA with values provided by the user. + Data has the keys from CONFIG_SCHEMA with values provided by the user. """ - options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD)) + options = ConnectionOptions( + ip_address=host, + username=data.get(CONF_USERNAME), + password=data.get(CONF_PASSWORD), + port=port, + ) gen = get_info_gen(info) @@ -114,8 +127,10 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Shelly.""" VERSION = 1 + MINOR_VERSION = 2 host: str = "" + port: int = DEFAULT_HTTP_PORT info: dict[str, Any] = {} device_info: dict[str, Any] = {} entry: ConfigEntry | None = None @@ -126,9 +141,10 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - host: str = user_input[CONF_HOST] + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] try: - self.info = await self._async_get_info(host) + self.info = await self._async_get_info(host, port) except DeviceConnectionError: errors["base"] = "cannot_connect" except FirmwareUnsupported: @@ -140,15 +156,18 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(self.info["mac"]) self._abort_if_unique_id_configured({CONF_HOST: host}) self.host = host + self.port = port if get_info_auth(self.info): return await self.async_step_credentials() try: device_info = await validate_input( - self.hass, self.host, self.info, {} + self.hass, host, port, self.info, {} ) except DeviceConnectionError: errors["base"] = "cannot_connect" + except CustomPortNotSupported: + errors["base"] = "custom_port_not_supported" except Exception: # pylint: disable=broad-except LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -157,7 +176,8 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=device_info["title"], data={ - **user_input, + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], "model": device_info["model"], CONF_GEN: device_info[CONF_GEN], @@ -166,7 +186,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "firmware_not_fully_provisioned" return self.async_show_form( - step_id="user", data_schema=HOST_SCHEMA, errors=errors + step_id="user", data_schema=CONFIG_SCHEMA, errors=errors ) async def async_step_credentials( @@ -179,7 +199,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_USERNAME] = "admin" try: device_info = await validate_input( - self.hass, self.host, self.info, user_input + self.hass, self.host, self.port, self.info, user_input ) except InvalidAuthError: errors["base"] = "invalid_auth" @@ -195,6 +215,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): data={ **user_input, CONF_HOST: self.host, + CONF_PORT: self.port, CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], "model": device_info["model"], CONF_GEN: device_info[CONF_GEN], @@ -254,7 +275,9 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): await self._async_discovered_mac(mac, host) try: - self.info = await self._async_get_info(host) + # Devices behind range extender doesn't generate zeroconf packets + # so port is always the default one + self.info = await self._async_get_info(host, DEFAULT_HTTP_PORT) except DeviceConnectionError: return self.async_abort(reason="cannot_connect") except FirmwareUnsupported: @@ -277,7 +300,9 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_credentials() try: - self.device_info = await validate_input(self.hass, self.host, self.info, {}) + self.device_info = await validate_input( + self.hass, self.host, self.port, self.info, {} + ) except DeviceConnectionError: return self.async_abort(reason="cannot_connect") @@ -329,17 +354,18 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} assert self.entry is not None host = self.entry.data[CONF_HOST] + port = get_http_port(self.entry.data) if user_input is not None: try: - info = await self._async_get_info(host) + info = await self._async_get_info(host, port) except (DeviceConnectionError, InvalidAuthError, FirmwareUnsupported): return self.async_abort(reason="reauth_unsuccessful") if get_device_entry_gen(self.entry) != 1: user_input[CONF_USERNAME] = "admin" try: - await validate_input(self.hass, host, info, user_input) + await validate_input(self.hass, host, port, info, user_input) except (DeviceConnectionError, InvalidAuthError, FirmwareUnsupported): return self.async_abort(reason="reauth_unsuccessful") @@ -361,9 +387,9 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _async_get_info(self, host: str) -> dict[str, Any]: + async def _async_get_info(self, host: str, port: int) -> dict[str, Any]: """Get info from shelly device.""" - return await get_info(async_get_clientsession(self.hass), host) + return await get_info(async_get_clientsession(self.hass), host, port=port) @staticmethod @callback diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index ebc92a6c6e0..50c352bcb25 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -59,6 +59,7 @@ from .const import ( ) from .utils import ( get_device_entry_gen, + get_http_port, get_rpc_device_wakeup_period, update_device_fw_info, ) @@ -140,7 +141,7 @@ class ShellyCoordinatorBase(DataUpdateCoordinator[None], Generic[_DeviceT]): model=MODEL_NAMES.get(self.model, self.model), sw_version=self.sw_version, hw_version=f"gen{get_device_entry_gen(self.entry)} ({self.model})", - configuration_url=f"http://{self.entry.data[CONF_HOST]}", + configuration_url=f"http://{self.entry.data[CONF_HOST]}:{get_http_port(self.entry.data)}", ) self.device_id = device_entry.id diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 9676c24f883..d4a8b117f4c 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -5,10 +5,12 @@ "user": { "description": "Before setup, battery-powered devices must be woken up, you can now wake the device up using a button on it.", "data": { - "host": "[%key:common::config_flow::data::host%]" + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" }, "data_description": { - "host": "The hostname or IP address of the Shelly device to connect to." + "host": "The hostname or IP address of the Shelly device to connect to.", + "port": "The TCP port of the Shelly device to connect to (Gen2+)." } }, "credentials": { @@ -31,7 +33,8 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support" + "firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support", + "custom_port_not_supported": "Gen1 device does not support custom port." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 291fe0cc4ea..dd0e685fd67 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import datetime, timedelta from ipaddress import IPv4Address +from types import MappingProxyType from typing import Any, cast from aiohttp.web import Request, WebSocketResponse @@ -11,6 +12,7 @@ from aioshelly.block_device import COAP, Block, BlockDevice from aioshelly.const import ( BLOCK_GENERATIONS, DEFAULT_COAP_PORT, + DEFAULT_HTTP_PORT, MODEL_1L, MODEL_DIMMER, MODEL_DIMMER_2, @@ -24,7 +26,7 @@ from aioshelly.rpc_device import RpcDevice, WsServer from homeassistant.components import network from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import issue_registry as ir, singleton from homeassistant.helpers.device_registry import ( @@ -473,3 +475,8 @@ def is_rpc_wifi_stations_disabled( return False return True + + +def get_http_port(data: MappingProxyType[str, Any]) -> int: + """Get port from config entry data.""" + return cast(int, data.get(CONF_PORT, DEFAULT_HTTP_PORT)) diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 1a680bcfc68..99b30062d43 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -6,8 +6,9 @@ from ipaddress import ip_address from typing import Any from unittest.mock import AsyncMock, Mock, patch -from aioshelly.const import MODEL_1, MODEL_PLUS_2PM +from aioshelly.const import DEFAULT_HTTP_PORT, MODEL_1, MODEL_PLUS_2PM from aioshelly.exceptions import ( + CustomPortNotSupported, DeviceConnectionError, FirmwareUnsupported, InvalidAuthError, @@ -54,17 +55,18 @@ DISCOVERY_INFO_WITH_MAC = zeroconf.ZeroconfServiceInfo( @pytest.mark.parametrize( - ("gen", "model"), + ("gen", "model", "port"), [ - (1, MODEL_1), - (2, MODEL_PLUS_2PM), - (3, MODEL_PLUS_2PM), + (1, MODEL_1, DEFAULT_HTTP_PORT), + (2, MODEL_PLUS_2PM, DEFAULT_HTTP_PORT), + (3, MODEL_PLUS_2PM, 11200), ], ) async def test_form( hass: HomeAssistant, gen: int, model: str, + port: int, mock_block_device: Mock, mock_rpc_device: Mock, ) -> None: @@ -72,12 +74,18 @@ async def test_form( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["errors"] == {} with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": MODEL_1, "auth": False, "gen": gen}, + return_value={ + "mac": "test-mac", + "type": MODEL_1, + "auth": False, + "gen": gen, + "port": port, + }, ), patch( "homeassistant.components.shelly.async_setup", return_value=True ) as mock_setup, patch( @@ -86,7 +94,7 @@ async def test_form( ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {"host": "1.1.1.1", "port": port}, ) await hass.async_block_till_done() @@ -94,6 +102,7 @@ async def test_form( assert result2["title"] == "Test name" assert result2["data"] == { "host": "1.1.1.1", + "port": port, "model": model, "sleep_period": 0, "gen": gen, @@ -102,6 +111,33 @@ async def test_form( assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_gen1_custom_port( + hass: HomeAssistant, + mock_block_device: Mock, +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "type": MODEL_1, "gen": 1}, + ), patch( + "aioshelly.block_device.BlockDevice.create", + side_effect=CustomPortNotSupported, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "1.1.1.1", "port": "1100"}, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"]["base"] == "custom_port_not_supported" + + @pytest.mark.parametrize( ("gen", "model", "user_input", "username"), [ @@ -168,6 +204,7 @@ async def test_form_auth( assert result3["title"] == "Test name" assert result3["data"] == { "host": "1.1.1.1", + "port": DEFAULT_HTTP_PORT, "model": model, "sleep_period": 0, "gen": gen, @@ -757,6 +794,7 @@ async def test_zeroconf_require_auth( assert result2["title"] == "Test name" assert result2["data"] == { "host": "1.1.1.1", + "port": DEFAULT_HTTP_PORT, "model": MODEL_1, "sleep_period": 0, "gen": 1, @@ -1126,7 +1164,7 @@ async def test_sleeping_device_gen2_with_new_firmware( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["errors"] == {} with patch( @@ -1144,6 +1182,7 @@ async def test_sleeping_device_gen2_with_new_firmware( assert result["data"] == { "host": "1.1.1.1", + "port": DEFAULT_HTTP_PORT, "model": MODEL_PLUS_2PM, "sleep_period": 666, "gen": 2, diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 700b54f153d..eb621a6e044 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -4,6 +4,8 @@ from ipaddress import IPv4Address from unittest.mock import AsyncMock, Mock, call, patch from aioshelly.block_device import COAP +from aioshelly.common import ConnectionOptions +from aioshelly.const import MODEL_PLUS_2PM from aioshelly.exceptions import ( DeviceConnectionError, FirmwareUnsupported, @@ -16,13 +18,14 @@ from homeassistant.components.shelly.const import ( BLOCK_EXPECTED_SLEEP_PERIOD, BLOCK_WRONG_SLEEP_PERIOD, CONF_BLE_SCANNER_MODE, + CONF_GEN, CONF_SLEEP_PERIOD, DOMAIN, MODELS_WITH_WRONG_SLEEP_PERIOD, BLEScannerMode, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import CONF_HOST, CONF_PORT, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, @@ -392,6 +395,49 @@ async def test_entry_missing_gen(hass: HomeAssistant, mock_block_device: Mock) - assert hass.states.get("switch.test_name_channel_1").state is STATE_ON +async def test_entry_missing_port(hass: HomeAssistant) -> None: + """Test successful Gen2 device init when port is missing in entry data.""" + data = { + CONF_HOST: "192.168.1.37", + CONF_SLEEP_PERIOD: 0, + "model": MODEL_PLUS_2PM, + CONF_GEN: 2, + } + entry = MockConfigEntry(domain=DOMAIN, data=data, unique_id=MOCK_MAC) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.shelly.RpcDevice.create", return_value=Mock() + ) as rpc_device_mock: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert rpc_device_mock.call_args[0][2] == ConnectionOptions( + ip_address="192.168.1.37", device_mac="123456789ABC", port=80 + ) + + +async def test_rpc_entry_custom_port(hass: HomeAssistant) -> None: + """Test successful Gen2 device init using custom port.""" + data = { + CONF_HOST: "192.168.1.37", + CONF_SLEEP_PERIOD: 0, + "model": MODEL_PLUS_2PM, + CONF_GEN: 2, + CONF_PORT: 8001, + } + entry = MockConfigEntry(domain=DOMAIN, data=data, unique_id=MOCK_MAC) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.shelly.RpcDevice.create", return_value=Mock() + ) as rpc_device_mock: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert rpc_device_mock.call_args[0][2] == ConnectionOptions( + ip_address="192.168.1.37", device_mac="123456789ABC", port=8001 + ) + + @pytest.mark.parametrize(("model"), MODELS_WITH_WRONG_SLEEP_PERIOD) async def test_sleeping_block_device_wrong_sleep_period( hass: HomeAssistant, mock_block_device: Mock, model: str