Add support for Shelly Gen3 devices (#104874)

* Add support for Gen3 devices

* Add RPC_GENERATIONS const

* Add gen3 to tests

* More tests

* Add BLOCK_GENERATIONS const

* Use *_GENERATIONS constants from aioshelly
This commit is contained in:
Maciej Bieniek 2023-12-11 22:58:56 +01:00 committed by GitHub
parent 662e19999d
commit bf93929826
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 55 additions and 27 deletions

View File

@ -6,6 +6,7 @@ from typing import Any, Final
from aioshelly.block_device import BlockDevice, BlockUpdateType from aioshelly.block_device import BlockDevice, BlockUpdateType
from aioshelly.common import ConnectionOptions from aioshelly.common import ConnectionOptions
from aioshelly.const import RPC_GENERATIONS
from aioshelly.exceptions import ( from aioshelly.exceptions import (
DeviceConnectionError, DeviceConnectionError,
InvalidAuthError, InvalidAuthError,
@ -123,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
get_entry_data(hass)[entry.entry_id] = ShellyEntryData() get_entry_data(hass)[entry.entry_id] = ShellyEntryData()
if get_device_entry_gen(entry) == 2: if get_device_entry_gen(entry) in RPC_GENERATIONS:
return await _async_setup_rpc_entry(hass, entry) return await _async_setup_rpc_entry(hass, entry)
return await _async_setup_block_entry(hass, entry) return await _async_setup_block_entry(hass, entry)
@ -313,7 +314,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if not entry.data.get(CONF_SLEEP_PERIOD): if not entry.data.get(CONF_SLEEP_PERIOD):
platforms = RPC_PLATFORMS platforms = RPC_PLATFORMS
if get_device_entry_gen(entry) == 2: if get_device_entry_gen(entry) in RPC_GENERATIONS:
if unload_ok := await hass.config_entries.async_unload_platforms( if unload_ok := await hass.config_entries.async_unload_platforms(
entry, platforms entry, platforms
): ):

View File

@ -4,6 +4,8 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Final, cast from typing import Final, cast
from aioshelly.const import RPC_GENERATIONS
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass, BinarySensorDeviceClass,
BinarySensorEntity, BinarySensorEntity,
@ -224,7 +226,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up sensors for device.""" """Set up sensors for device."""
if get_device_entry_gen(config_entry) == 2: if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
if config_entry.data[CONF_SLEEP_PERIOD]: if config_entry.data[CONF_SLEEP_PERIOD]:
async_setup_entry_rpc( async_setup_entry_rpc(
hass, hass,

View File

@ -5,6 +5,8 @@ from collections.abc import Callable, Coroutine
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar
from aioshelly.const import RPC_GENERATIONS
from homeassistant.components.button import ( from homeassistant.components.button import (
ButtonDeviceClass, ButtonDeviceClass,
ButtonEntity, ButtonEntity,
@ -126,7 +128,7 @@ async def async_setup_entry(
return async_migrate_unique_ids(entity_entry, coordinator) return async_migrate_unique_ids(entity_entry, coordinator)
coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None = None coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None = None
if get_device_entry_gen(config_entry) == 2: if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
coordinator = get_entry_data(hass)[config_entry.entry_id].rpc coordinator = get_entry_data(hass)[config_entry.entry_id].rpc
else: else:
coordinator = get_entry_data(hass)[config_entry.entry_id].block coordinator = get_entry_data(hass)[config_entry.entry_id].block

View File

@ -6,6 +6,7 @@ from dataclasses import asdict, dataclass
from typing import Any, cast from typing import Any, cast
from aioshelly.block_device import Block from aioshelly.block_device import Block
from aioshelly.const import RPC_GENERATIONS
from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError
from homeassistant.components.climate import ( from homeassistant.components.climate import (
@ -51,7 +52,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up climate device.""" """Set up climate device."""
if get_device_entry_gen(config_entry) == 2: if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
return async_setup_rpc_entry(hass, config_entry, async_add_entities) return async_setup_rpc_entry(hass, config_entry, async_add_entities)
coordinator = get_entry_data(hass)[config_entry.entry_id].block coordinator = get_entry_data(hass)[config_entry.entry_id].block

View File

@ -6,6 +6,7 @@ from typing import Any, Final
from aioshelly.block_device import BlockDevice from aioshelly.block_device import BlockDevice
from aioshelly.common import ConnectionOptions, get_info from aioshelly.common import ConnectionOptions, get_info
from aioshelly.const import BLOCK_GENERATIONS, RPC_GENERATIONS
from aioshelly.exceptions import ( from aioshelly.exceptions import (
DeviceConnectionError, DeviceConnectionError,
FirmwareUnsupported, FirmwareUnsupported,
@ -66,7 +67,9 @@ async def validate_input(
""" """
options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD)) options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD))
if get_info_gen(info) == 2: gen = get_info_gen(info)
if gen in RPC_GENERATIONS:
ws_context = await get_ws_context(hass) ws_context = await get_ws_context(hass)
rpc_device = await RpcDevice.create( rpc_device = await RpcDevice.create(
async_get_clientsession(hass), async_get_clientsession(hass),
@ -81,7 +84,7 @@ async def validate_input(
"title": rpc_device.name, "title": rpc_device.name,
CONF_SLEEP_PERIOD: sleep_period, CONF_SLEEP_PERIOD: sleep_period,
"model": rpc_device.shelly.get("model"), "model": rpc_device.shelly.get("model"),
"gen": 2, "gen": gen,
} }
# Gen1 # Gen1
@ -96,7 +99,7 @@ async def validate_input(
"title": block_device.name, "title": block_device.name,
CONF_SLEEP_PERIOD: get_block_device_sleep_period(block_device.settings), CONF_SLEEP_PERIOD: get_block_device_sleep_period(block_device.settings),
"model": block_device.model, "model": block_device.model,
"gen": 1, "gen": gen,
} }
@ -165,7 +168,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the credentials step.""" """Handle the credentials step."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
if get_info_gen(self.info) == 2: if get_info_gen(self.info) in RPC_GENERATIONS:
user_input[CONF_USERNAME] = "admin" user_input[CONF_USERNAME] = "admin"
try: try:
device_info = await validate_input( device_info = await validate_input(
@ -194,7 +197,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
else: else:
user_input = {} user_input = {}
if get_info_gen(self.info) == 2: if get_info_gen(self.info) in RPC_GENERATIONS:
schema = { schema = {
vol.Required(CONF_PASSWORD, default=user_input.get(CONF_PASSWORD)): str, vol.Required(CONF_PASSWORD, default=user_input.get(CONF_PASSWORD)): str,
} }
@ -331,7 +334,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
await self.hass.config_entries.async_reload(self.entry.entry_id) await self.hass.config_entries.async_reload(self.entry.entry_id)
return self.async_abort(reason="reauth_successful") return self.async_abort(reason="reauth_successful")
if self.entry.data.get("gen", 1) == 1: if self.entry.data.get("gen", 1) in BLOCK_GENERATIONS:
schema = { schema = {
vol.Required(CONF_USERNAME): str, vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str, vol.Required(CONF_PASSWORD): str,
@ -360,7 +363,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool:
"""Return options flow support for this handler.""" """Return options flow support for this handler."""
return ( return (
config_entry.data.get("gen") == 2 config_entry.data.get("gen") in RPC_GENERATIONS
and not config_entry.data.get(CONF_SLEEP_PERIOD) and not config_entry.data.get(CONF_SLEEP_PERIOD)
and config_entry.data.get("model") != MODEL_WALL_DISPLAY and config_entry.data.get("model") != MODEL_WALL_DISPLAY
) )

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Any, cast from typing import Any, cast
from aioshelly.block_device import Block from aioshelly.block_device import Block
from aioshelly.const import RPC_GENERATIONS
from homeassistant.components.cover import ( from homeassistant.components.cover import (
ATTR_POSITION, ATTR_POSITION,
@ -26,7 +27,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up covers for device.""" """Set up covers for device."""
if get_device_entry_gen(config_entry) == 2: if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
return async_setup_rpc_entry(hass, config_entry, async_add_entities) return async_setup_rpc_entry(hass, config_entry, async_add_entities)
return async_setup_block_entry(hass, config_entry, async_add_entities) return async_setup_block_entry(hass, config_entry, async_add_entities)

View File

@ -6,7 +6,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Final from typing import TYPE_CHECKING, Any, Final
from aioshelly.block_device import Block from aioshelly.block_device import Block
from aioshelly.const import MODEL_I3 from aioshelly.const import MODEL_I3, RPC_GENERATIONS
from homeassistant.components.event import ( from homeassistant.components.event import (
DOMAIN as EVENT_DOMAIN, DOMAIN as EVENT_DOMAIN,
@ -80,7 +80,7 @@ async def async_setup_entry(
coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None = None coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None = None
if get_device_entry_gen(config_entry) == 2: if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
coordinator = get_entry_data(hass)[config_entry.entry_id].rpc coordinator = get_entry_data(hass)[config_entry.entry_id].rpc
if TYPE_CHECKING: if TYPE_CHECKING:
assert coordinator assert coordinator

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any, cast from typing import Any, cast
from aioshelly.block_device import Block from aioshelly.block_device import Block
from aioshelly.const import MODEL_BULB from aioshelly.const import MODEL_BULB, RPC_GENERATIONS
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
@ -53,7 +53,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up lights for device.""" """Set up lights for device."""
if get_device_entry_gen(config_entry) == 2: if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
return async_setup_rpc_entry(hass, config_entry, async_add_entities) return async_setup_rpc_entry(hass, config_entry, async_add_entities)
return async_setup_block_entry(hass, config_entry, async_add_entities) return async_setup_block_entry(hass, config_entry, async_add_entities)

View File

@ -6,6 +6,7 @@ from dataclasses import dataclass
from typing import Final, cast from typing import Final, cast
from aioshelly.block_device import Block from aioshelly.block_device import Block
from aioshelly.const import RPC_GENERATIONS
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
RestoreSensor, RestoreSensor,
@ -925,7 +926,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up sensors for device.""" """Set up sensors for device."""
if get_device_entry_gen(config_entry) == 2: if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
if config_entry.data[CONF_SLEEP_PERIOD]: if config_entry.data[CONF_SLEEP_PERIOD]:
async_setup_entry_rpc( async_setup_entry_rpc(
hass, hass,

View File

@ -5,7 +5,7 @@ from dataclasses import dataclass
from typing import Any, cast from typing import Any, cast
from aioshelly.block_device import Block from aioshelly.block_device import Block
from aioshelly.const import MODEL_2, MODEL_25, MODEL_GAS from aioshelly.const import MODEL_2, MODEL_25, MODEL_GAS, RPC_GENERATIONS
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -49,7 +49,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up switches for device.""" """Set up switches for device."""
if get_device_entry_gen(config_entry) == 2: if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
return async_setup_rpc_entry(hass, config_entry, async_add_entities) return async_setup_rpc_entry(hass, config_entry, async_add_entities)
return async_setup_block_entry(hass, config_entry, async_add_entities) return async_setup_block_entry(hass, config_entry, async_add_entities)

View File

@ -6,6 +6,7 @@ from dataclasses import dataclass
import logging import logging
from typing import Any, Final, cast from typing import Any, Final, cast
from aioshelly.const import RPC_GENERATIONS
from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError
from homeassistant.components.update import ( from homeassistant.components.update import (
@ -119,7 +120,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up update entities for Shelly component.""" """Set up update entities for Shelly component."""
if get_device_entry_gen(config_entry) == 2: if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
if config_entry.data[CONF_SLEEP_PERIOD]: if config_entry.data[CONF_SLEEP_PERIOD]:
async_setup_entry_rpc( async_setup_entry_rpc(
hass, hass,

View File

@ -7,12 +7,14 @@ from typing import Any, cast
from aiohttp.web import Request, WebSocketResponse from aiohttp.web import Request, WebSocketResponse
from aioshelly.block_device import COAP, Block, BlockDevice from aioshelly.block_device import COAP, Block, BlockDevice
from aioshelly.const import ( from aioshelly.const import (
BLOCK_GENERATIONS,
MODEL_1L, MODEL_1L,
MODEL_DIMMER, MODEL_DIMMER,
MODEL_DIMMER_2, MODEL_DIMMER_2,
MODEL_EM3, MODEL_EM3,
MODEL_I3, MODEL_I3,
MODEL_NAMES, MODEL_NAMES,
RPC_GENERATIONS,
) )
from aioshelly.rpc_device import RpcDevice, WsServer from aioshelly.rpc_device import RpcDevice, WsServer
@ -284,7 +286,7 @@ def get_info_gen(info: dict[str, Any]) -> int:
def get_model_name(info: dict[str, Any]) -> str: def get_model_name(info: dict[str, Any]) -> str:
"""Return the device model name.""" """Return the device model name."""
if get_info_gen(info) == 2: if get_info_gen(info) in RPC_GENERATIONS:
return cast(str, MODEL_NAMES.get(info["model"], info["model"])) return cast(str, MODEL_NAMES.get(info["model"], info["model"]))
return cast(str, MODEL_NAMES.get(info["type"], info["type"])) return cast(str, MODEL_NAMES.get(info["type"], info["type"]))
@ -420,4 +422,4 @@ def get_release_url(gen: int, model: str, beta: bool) -> str | None:
if beta or model in DEVICES_WITHOUT_FIRMWARE_CHANGELOG: if beta or model in DEVICES_WITHOUT_FIRMWARE_CHANGELOG:
return None return None
return GEN1_RELEASE_URL if gen == 1 else GEN2_RELEASE_URL return GEN1_RELEASE_URL if gen in BLOCK_GENERATIONS else GEN2_RELEASE_URL

View File

@ -55,6 +55,7 @@ DISCOVERY_INFO_WITH_MAC = zeroconf.ZeroconfServiceInfo(
[ [
(1, MODEL_1), (1, MODEL_1),
(2, MODEL_PLUS_2PM), (2, MODEL_PLUS_2PM),
(3, MODEL_PLUS_2PM),
], ],
) )
async def test_form( async def test_form(
@ -109,6 +110,12 @@ async def test_form(
{"password": "test2 password"}, {"password": "test2 password"},
"admin", "admin",
), ),
(
3,
MODEL_PLUS_2PM,
{"password": "test2 password"},
"admin",
),
], ],
) )
async def test_form_auth( async def test_form_auth(
@ -465,6 +472,11 @@ async def test_form_auth_errors_test_connection_gen2(
MODEL_PLUS_2PM, MODEL_PLUS_2PM,
{"mac": "test-mac", "model": MODEL_PLUS_2PM, "auth": False, "gen": 2}, {"mac": "test-mac", "model": MODEL_PLUS_2PM, "auth": False, "gen": 2},
), ),
(
3,
MODEL_PLUS_2PM,
{"mac": "test-mac", "model": MODEL_PLUS_2PM, "auth": False, "gen": 3},
),
], ],
) )
async def test_zeroconf( async def test_zeroconf(
@ -742,6 +754,7 @@ async def test_zeroconf_require_auth(hass: HomeAssistant, mock_block_device) ->
[ [
(1, {"username": "test user", "password": "test1 password"}), (1, {"username": "test user", "password": "test1 password"}),
(2, {"password": "test2 password"}), (2, {"password": "test2 password"}),
(3, {"password": "test2 password"}),
], ],
) )
async def test_reauth_successful( async def test_reauth_successful(
@ -780,6 +793,7 @@ async def test_reauth_successful(
[ [
(1, {"username": "test user", "password": "test1 password"}), (1, {"username": "test user", "password": "test1 password"}),
(2, {"password": "test2 password"}), (2, {"password": "test2 password"}),
(3, {"password": "test2 password"}),
], ],
) )
async def test_reauth_unsuccessful(hass: HomeAssistant, gen, user_input) -> None: async def test_reauth_unsuccessful(hass: HomeAssistant, gen, user_input) -> None:

View File

@ -41,7 +41,7 @@ async def test_custom_coap_port(
assert "Starting CoAP context with UDP port 7632" in caplog.text assert "Starting CoAP context with UDP port 7632" in caplog.text
@pytest.mark.parametrize("gen", [1, 2]) @pytest.mark.parametrize("gen", [1, 2, 3])
async def test_shared_device_mac( async def test_shared_device_mac(
hass: HomeAssistant, hass: HomeAssistant,
gen, gen,
@ -74,7 +74,7 @@ async def test_setup_entry_not_shelly(
assert "probably comes from a custom integration" in caplog.text assert "probably comes from a custom integration" in caplog.text
@pytest.mark.parametrize("gen", [1, 2]) @pytest.mark.parametrize("gen", [1, 2, 3])
async def test_device_connection_error( async def test_device_connection_error(
hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch
) -> None: ) -> None:
@ -90,7 +90,7 @@ async def test_device_connection_error(
assert entry.state == ConfigEntryState.SETUP_RETRY assert entry.state == ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize("gen", [1, 2]) @pytest.mark.parametrize("gen", [1, 2, 3])
async def test_mac_mismatch_error( async def test_mac_mismatch_error(
hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch
) -> None: ) -> None:
@ -106,7 +106,7 @@ async def test_mac_mismatch_error(
assert entry.state == ConfigEntryState.SETUP_RETRY assert entry.state == ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize("gen", [1, 2]) @pytest.mark.parametrize("gen", [1, 2, 3])
async def test_device_auth_error( async def test_device_auth_error(
hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch
) -> None: ) -> None: