Add support for Shelly RPC devices custom TCP port (#110860)

* First coding

* add port to config_entry + gen1 not supported msg

* fix async_step_credentials

* strings

* fix reauth

* fix visit device link

* increased MINOR_VERSION

* apply review comments

* align to latest aioshelly

* missing tests

* introduce port parameter

* update tests

* remove leftover

* remove "port" data_description key

* missing key

* apply review comments

* apply more review comments

* Add tests

* apply review comment

* apply review comment (part 2)

* description update

* fine tuning description

* fix test patching

---------

Co-authored-by: Shay Levy <levyshay1@gmail.com>
This commit is contained in:
Simone Chemelli 2024-03-21 19:58:56 +01:00 committed by GitHub
parent 8141a246b0
commit 8728057b1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 156 additions and 32 deletions

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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%]",

View file

@ -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))

View file

@ -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,

View file

@ -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