Add switch support for RPC device (#56153)

* Add switch support for RPC device

* Apply review comments

* Apply review comments
This commit is contained in:
Shay Levy 2021-09-13 09:31:35 +03:00 committed by GitHub
parent f1a88f0563
commit e9eb76c7db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 568 additions and 122 deletions

View File

@ -8,6 +8,7 @@ from typing import Any, Final, cast
import aioshelly
from aioshelly.block_device import BlockDevice
from aioshelly.rpc_device import RpcDevice
import async_timeout
import voluptuous as vol
@ -31,7 +32,7 @@ from .const import (
ATTR_CLICK_TYPE,
ATTR_DEVICE,
BATTERY_DEVICES_WITH_PERMANENT_CONNECTION,
COAP,
BLOCK,
CONF_COAP_PORT,
DATA_CONFIG_ENTRY,
DEFAULT_COAP_PORT,
@ -42,6 +43,8 @@ from .const import (
POLLING_TIMEOUT_SEC,
REST,
REST_SENSORS_UPDATE_INTERVAL,
RPC,
RPC_RECONNECT_INTERVAL,
SHBTN_MODELS,
SLEEP_PERIOD_MULTIPLIER,
UPDATE_PERIOD_MULTIPLIER,
@ -50,10 +53,13 @@ from .utils import (
get_block_device_name,
get_block_device_sleep_period,
get_coap_context,
get_device_entry_gen,
get_rpc_device_name,
)
PLATFORMS: Final = ["binary_sensor", "cover", "light", "sensor", "switch"]
SLEEPING_PLATFORMS: Final = ["binary_sensor", "sensor"]
BLOCK_PLATFORMS: Final = ["binary_sensor", "cover", "light", "sensor", "switch"]
BLOCK_SLEEPING_PLATFORMS: Final = ["binary_sensor", "sensor"]
RPC_PLATFORMS: Final = ["light", "switch"]
_LOGGER: Final = logging.getLogger(__name__)
COAP_SCHEMA: Final = vol.Schema(
@ -89,12 +95,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
return False
if entry.data.get("gen") == 2:
return True
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = {}
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = None
if get_device_entry_gen(entry) == 2:
return await async_setup_rpc_entry(hass, entry)
return await async_setup_block_entry(hass, entry)
async def async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Shelly block based device from a config entry."""
temperature_unit = "C" if hass.config.units.is_metric else "F"
options = aioshelly.common.ConnectionOptions(
@ -113,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
False,
)
dev_reg = await device_registry.async_get_registry(hass)
dev_reg = device_registry.async_get(hass)
device_entry = None
if entry.unique_id is not None:
device_entry = dev_reg.async_get_device(
@ -135,18 +146,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
data["model"] = device.settings["device"]["type"]
hass.config_entries.async_update_entry(entry, data=data)
hass.async_create_task(async_device_setup(hass, entry, device))
hass.async_create_task(async_block_device_setup(hass, entry, device))
if sleep_period == 0:
# Not a sleeping device, finish setup
_LOGGER.debug("Setting up online device %s", entry.title)
_LOGGER.debug("Setting up online block device %s", entry.title)
try:
async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC):
await device.initialize()
except (asyncio.TimeoutError, OSError) as err:
raise ConfigEntryNotReady from err
await async_device_setup(hass, entry, device)
await async_block_device_setup(hass, entry, device)
elif sleep_period is None or device_entry is None:
# Need to get sleep info or first time sleeping device setup, wait for device
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = device
@ -156,34 +167,61 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
device.subscribe_updates(_async_device_online)
else:
# Restore sensors for sleeping device
_LOGGER.debug("Setting up offline device %s", entry.title)
await async_device_setup(hass, entry, device)
_LOGGER.debug("Setting up offline block device %s", entry.title)
await async_block_device_setup(hass, entry, device)
return True
async def async_device_setup(
async def async_block_device_setup(
hass: HomeAssistant, entry: ConfigEntry, device: BlockDevice
) -> None:
"""Set up a device that is online."""
"""Set up a block based device that is online."""
device_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][
COAP
] = ShellyDeviceWrapper(hass, entry, device)
await device_wrapper.async_setup()
BLOCK
] = BlockDeviceWrapper(hass, entry, device)
device_wrapper.async_setup()
platforms = SLEEPING_PLATFORMS
platforms = BLOCK_SLEEPING_PLATFORMS
if not entry.data.get("sleep_period"):
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][
REST
] = ShellyDeviceRestWrapper(hass, device)
platforms = PLATFORMS
platforms = BLOCK_PLATFORMS
hass.config_entries.async_setup_platforms(entry, platforms)
class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
"""Wrapper for a Shelly device with Home Assistant specific functions."""
async def async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Shelly RPC based device from a config entry."""
options = aioshelly.common.ConnectionOptions(
entry.data[CONF_HOST],
entry.data.get(CONF_USERNAME),
entry.data.get(CONF_PASSWORD),
)
_LOGGER.debug("Setting up online RPC device %s", entry.title)
try:
async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC):
device = await RpcDevice.create(
aiohttp_client.async_get_clientsession(hass), options
)
except (asyncio.TimeoutError, OSError) as err:
raise ConfigEntryNotReady from err
device_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][
RPC
] = RpcDeviceWrapper(hass, entry, device)
device_wrapper.async_setup()
hass.config_entries.async_setup_platforms(entry, RPC_PLATFORMS)
return True
class BlockDeviceWrapper(update_coordinator.DataUpdateCoordinator):
"""Wrapper for a Shelly block based device with Home Assistant specific functions."""
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, device: BlockDevice
@ -283,7 +321,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
# Sleeping device, no point polling it, just mark it unavailable
raise update_coordinator.UpdateFailed("Sleeping device did not update")
_LOGGER.debug("Polling Shelly Device - %s", self.name)
_LOGGER.debug("Polling Shelly Block Device - %s", self.name)
try:
async with async_timeout.timeout(POLLING_TIMEOUT_SEC):
await self.device.update()
@ -300,16 +338,14 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
"""Mac address of the device."""
return cast(str, self.entry.unique_id)
async def async_setup(self) -> None:
def async_setup(self) -> None:
"""Set up the wrapper."""
dev_reg = await device_registry.async_get_registry(self.hass)
sw_version = self.device.settings["fw"] if self.device.initialized else ""
dev_reg = device_registry.async_get(self.hass)
sw_version = self.device.firmware_version if self.device.initialized else ""
entry = dev_reg.async_get_or_create(
config_entry_id=self.entry.entry_id,
name=self.name,
connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac)},
# This is duplicate but otherwise via_device can't work
identifiers={(DOMAIN, self.mac)},
manufacturer="Shelly",
model=aioshelly.const.MODEL_NAMES.get(self.model, self.model),
sw_version=sw_version,
@ -325,7 +361,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
@callback
def _handle_ha_stop(self, _event: Event) -> None:
"""Handle Home Assistant stopping."""
_LOGGER.debug("Stopping ShellyDeviceWrapper for %s", self.name)
_LOGGER.debug("Stopping BlockDeviceWrapper for %s", self.name)
self.shutdown()
@ -369,8 +405,15 @@ class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator):
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if entry.data.get("gen") == 2:
return True
if get_device_entry_gen(entry) == 2:
unload_ok = await hass.config_entries.async_unload_platforms(
entry, RPC_PLATFORMS
)
if unload_ok:
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][RPC].shutdown()
hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id)
return unload_ok
device = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id].get(DEVICE)
if device is not None:
@ -378,15 +421,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
device.shutdown()
return True
platforms = SLEEPING_PLATFORMS
platforms = BLOCK_SLEEPING_PLATFORMS
if not entry.data.get("sleep_period"):
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][REST] = None
platforms = PLATFORMS
platforms = BLOCK_PLATFORMS
unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms)
if unload_ok:
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][COAP].shutdown()
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][BLOCK].shutdown()
hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id)
return unload_ok
@ -394,17 +437,94 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
def get_device_wrapper(
hass: HomeAssistant, device_id: str
) -> ShellyDeviceWrapper | None:
) -> BlockDeviceWrapper | RpcDeviceWrapper | None:
"""Get a Shelly device wrapper for the given device id."""
if not hass.data.get(DOMAIN):
return None
for config_entry in hass.data[DOMAIN][DATA_CONFIG_ENTRY]:
wrapper: ShellyDeviceWrapper | None = hass.data[DOMAIN][DATA_CONFIG_ENTRY][
block_wrapper: BlockDeviceWrapper | None = hass.data[DOMAIN][DATA_CONFIG_ENTRY][
config_entry
].get(COAP)
].get(BLOCK)
if wrapper and wrapper.device_id == device_id:
return wrapper
if block_wrapper and block_wrapper.device_id == device_id:
return block_wrapper
rpc_wrapper: RpcDeviceWrapper | None = hass.data[DOMAIN][DATA_CONFIG_ENTRY][
config_entry
].get(RPC)
if rpc_wrapper and rpc_wrapper.device_id == device_id:
return rpc_wrapper
return None
class RpcDeviceWrapper(update_coordinator.DataUpdateCoordinator):
"""Wrapper for a Shelly RPC based device with Home Assistant specific functions."""
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, device: RpcDevice
) -> None:
"""Initialize the Shelly device wrapper."""
self.device_id: str | None = None
device_name = get_rpc_device_name(device) if device.initialized else entry.title
super().__init__(
hass,
_LOGGER,
name=device_name,
update_interval=timedelta(seconds=RPC_RECONNECT_INTERVAL),
)
self.entry = entry
self.device = device
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop)
)
async def _async_update_data(self) -> None:
"""Fetch data."""
if self.device.connected:
return
try:
_LOGGER.debug("Reconnecting to Shelly RPC Device - %s", self.name)
async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC):
await self.device.initialize()
except OSError as err:
raise update_coordinator.UpdateFailed("Device disconnected") from err
@property
def model(self) -> str:
"""Model of the device."""
return cast(str, self.entry.data["model"])
@property
def mac(self) -> str:
"""Mac address of the device."""
return cast(str, self.entry.unique_id)
def async_setup(self) -> None:
"""Set up the wrapper."""
dev_reg = device_registry.async_get(self.hass)
sw_version = self.device.firmware_version if self.device.initialized else ""
entry = dev_reg.async_get_or_create(
config_entry_id=self.entry.entry_id,
name=self.name,
connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac)},
manufacturer="Shelly",
model=aioshelly.const.MODEL_NAMES.get(self.model, self.model),
sw_version=sw_version,
)
self.device_id = entry.id
self.device.subscribe_updates(self.async_set_updated_data)
async def shutdown(self) -> None:
"""Shutdown the wrapper."""
await self.device.shutdown()
async def _handle_ha_stop(self, _event: Event) -> None:
"""Handle Home Assistant stopping."""
_LOGGER.debug("Stopping RpcDeviceWrapper for %s", self.name)
await self.shutdown()

View File

@ -4,11 +4,12 @@ from __future__ import annotations
import re
from typing import Final
COAP: Final = "coap"
BLOCK: Final = "block"
DATA_CONFIG_ENTRY: Final = "config_entry"
DEVICE: Final = "device"
DOMAIN: Final = "shelly"
REST: Final = "rest"
RPC: Final = "rpc"
CONF_COAP_PORT: Final = "coap_port"
DEFAULT_COAP_PORT: Final = 5683
@ -44,6 +45,9 @@ SLEEP_PERIOD_MULTIPLIER: Final = 1.2
# Multiplier used to calculate the "update_interval" for non-sleeping devices.
UPDATE_PERIOD_MULTIPLIER: Final = 2.2
# Reconnect interval for GEN2 devices
RPC_RECONNECT_INTERVAL = 60
# Shelly Air - Maximum work hours before lamp replacement
SHAIR_MAX_WORK_HOURS: Final = 9000

View File

@ -18,8 +18,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ShellyDeviceWrapper
from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN
from . import BlockDeviceWrapper
from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN
from .entity import ShellyBlockEntity
@ -29,7 +29,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up cover for device."""
wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP]
wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][BLOCK]
blocks = [block for block in wrapper.device.blocks if block.type == "roller"]
if not blocks:
@ -43,7 +43,7 @@ class ShellyCover(ShellyBlockEntity, CoverEntity):
_attr_device_class = DEVICE_CLASS_SHUTTER
def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None:
def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None:
"""Initialize light."""
super().__init__(wrapper, block)
self.control_result: dict[str, Any] | None = None

View File

@ -25,7 +25,7 @@ from homeassistant.const import (
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers.typing import ConfigType
from . import get_device_wrapper
from . import RpcDeviceWrapper, get_device_wrapper
from .const import (
ATTR_CHANNEL,
ATTR_CLICK_TYPE,
@ -55,6 +55,10 @@ async def async_validate_trigger_config(
# if device is available verify parameters against device capabilities
wrapper = get_device_wrapper(hass, config[CONF_DEVICE_ID])
if isinstance(wrapper, RpcDeviceWrapper):
return config
if not wrapper or not wrapper.device.initialized:
return config
@ -76,12 +80,15 @@ async def async_get_triggers(
hass: HomeAssistant, device_id: str
) -> list[dict[str, Any]]:
"""List device triggers for Shelly devices."""
triggers = []
wrapper = get_device_wrapper(hass, device_id)
if not wrapper:
raise InvalidDeviceAutomationConfig(f"Device not found: {device_id}")
if isinstance(wrapper, RpcDeviceWrapper):
return []
triggers = []
if wrapper.model in SHBTN_MODELS:
for trigger in SHBTN_INPUTS_EVENTS_TYPES:
triggers.append(

View File

@ -23,9 +23,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import StateType
from . import ShellyDeviceRestWrapper, ShellyDeviceWrapper
from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, COAP, DATA_CONFIG_ENTRY, DOMAIN, REST
from .utils import async_remove_shelly_entity, get_entity_name
from . import BlockDeviceWrapper, RpcDeviceWrapper, ShellyDeviceRestWrapper
from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, BLOCK, DATA_CONFIG_ENTRY, DOMAIN, REST
from .utils import (
async_remove_shelly_entity,
get_block_entity_name,
get_rpc_entity_name,
)
_LOGGER: Final = logging.getLogger(__name__)
@ -38,9 +42,9 @@ async def async_setup_entry_attribute_entities(
sensor_class: Callable,
) -> None:
"""Set up entities for attributes."""
wrapper: ShellyDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][
wrapper: BlockDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][
config_entry.entry_id
][COAP]
][BLOCK]
if wrapper.device.initialized:
await async_setup_block_attribute_entities(
@ -55,7 +59,7 @@ async def async_setup_entry_attribute_entities(
async def async_setup_block_attribute_entities(
hass: HomeAssistant,
async_add_entities: AddEntitiesCallback,
wrapper: ShellyDeviceWrapper,
wrapper: BlockDeviceWrapper,
sensors: dict[tuple[str, str], BlockAttributeDescription],
sensor_class: Callable,
) -> None:
@ -99,7 +103,7 @@ async def async_restore_block_attribute_entities(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
wrapper: ShellyDeviceWrapper,
wrapper: BlockDeviceWrapper,
sensors: dict[tuple[str, str], BlockAttributeDescription],
sensor_class: Callable,
) -> None:
@ -198,13 +202,13 @@ class RestAttributeDescription:
class ShellyBlockEntity(entity.Entity):
"""Helper class to represent a block."""
"""Helper class to represent a block entity."""
def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None:
def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None:
"""Initialize Shelly entity."""
self.wrapper = wrapper
self.block = block
self._name = get_entity_name(wrapper.device, block)
self._name = get_block_entity_name(wrapper.device, block)
@property
def name(self) -> str:
@ -263,12 +267,67 @@ class ShellyBlockEntity(entity.Entity):
return None
class ShellyRpcEntity(entity.Entity):
"""Helper class to represent a rpc entity."""
def __init__(self, wrapper: RpcDeviceWrapper, key: str) -> None:
"""Initialize Shelly entity."""
self.wrapper = wrapper
self.key = key
self._attr_should_poll = False
self._attr_device_info = {
"connections": {(device_registry.CONNECTION_NETWORK_MAC, wrapper.mac)}
}
self._attr_unique_id = f"{wrapper.mac}-{key}"
self._attr_name = get_rpc_entity_name(wrapper.device, key)
@property
def available(self) -> bool:
"""Available."""
return self.wrapper.device.connected
async def async_added_to_hass(self) -> None:
"""When entity is added to HASS."""
self.async_on_remove(self.wrapper.async_add_listener(self._update_callback))
async def async_update(self) -> None:
"""Update entity with latest info."""
await self.wrapper.async_request_refresh()
@callback
def _update_callback(self) -> None:
"""Handle device update."""
self.async_write_ha_state()
async def call_rpc(self, method: str, params: Any) -> Any:
"""Call RPC method."""
_LOGGER.debug(
"Call RPC for entity %s, method: %s, params: %s",
self.name,
method,
params,
)
try:
async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC):
return await self.wrapper.device.call_rpc(method, params)
except asyncio.TimeoutError as err:
_LOGGER.error(
"Call RPC for entity %s failed, method: %s, params: %s, error: %s",
self.name,
method,
params,
repr(err),
)
self.wrapper.last_update_success = False
return None
class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity):
"""Helper class to represent a block attribute."""
def __init__(
self,
wrapper: ShellyDeviceWrapper,
wrapper: BlockDeviceWrapper,
block: Block,
attribute: str,
description: BlockAttributeDescription,
@ -285,7 +344,7 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity):
self._unit: None | str | Callable[[dict], str] = unit
self._unique_id: str = f"{super().unique_id}-{self.attribute}"
self._name = get_entity_name(wrapper.device, block, self.description.name)
self._name = get_block_entity_name(wrapper.device, block, self.description.name)
@property
def unique_id(self) -> str:
@ -346,7 +405,7 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity):
def __init__(
self,
wrapper: ShellyDeviceWrapper,
wrapper: BlockDeviceWrapper,
attribute: str,
description: RestAttributeDescription,
) -> None:
@ -355,7 +414,7 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity):
self.wrapper = wrapper
self.attribute = attribute
self.description = description
self._name = get_entity_name(wrapper.device, None, self.description.name)
self._name = get_block_entity_name(wrapper.device, None, self.description.name)
self._last_value = None
@property
@ -419,7 +478,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti
# pylint: disable=super-init-not-called
def __init__(
self,
wrapper: ShellyDeviceWrapper,
wrapper: BlockDeviceWrapper,
block: Block | None,
attribute: str,
description: BlockAttributeDescription,
@ -440,7 +499,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti
self._unit = self._unit(block.info(attribute))
self._unique_id = f"{self.wrapper.mac}-{block.description}-{attribute}"
self._name = get_entity_name(
self._name = get_block_entity_name(
self.wrapper.device, block, self.description.name
)
elif entry is not None:

View File

@ -33,10 +33,10 @@ from homeassistant.util.color import (
color_temperature_mired_to_kelvin,
)
from . import ShellyDeviceWrapper
from . import BlockDeviceWrapper, RpcDeviceWrapper
from .const import (
AIOSHELLY_DEVICE_TIMEOUT_SEC,
COAP,
BLOCK,
DATA_CONFIG_ENTRY,
DOMAIN,
FIRMWARE_PATTERN,
@ -46,11 +46,12 @@ from .const import (
LIGHT_TRANSITION_MIN_FIRMWARE_DATE,
MAX_TRANSITION_TIME,
MODELS_SUPPORTING_LIGHT_TRANSITION,
RPC,
SHBLB_1_RGB_EFFECTS,
STANDARD_RGB_EFFECTS,
)
from .entity import ShellyBlockEntity
from .utils import async_remove_shelly_entity
from .entity import ShellyBlockEntity, ShellyRpcEntity
from .utils import async_remove_shelly_entity, get_device_entry_gen
_LOGGER: Final = logging.getLogger(__name__)
@ -61,33 +62,75 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up lights for device."""
wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP]
if get_device_entry_gen(config_entry) == 2:
return await async_setup_rpc_entry(hass, config_entry, async_add_entities)
return await async_setup_block_entry(hass, config_entry, async_add_entities)
async def async_setup_block_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entities for block device."""
wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][BLOCK]
blocks = []
assert wrapper.device.blocks
for block in wrapper.device.blocks:
if block.type == "light":
blocks.append(block)
elif block.type == "relay":
appliance_type = wrapper.device.settings["relays"][int(block.channel)].get(
app_type = wrapper.device.settings["relays"][int(block.channel)].get(
"appliance_type"
)
if appliance_type and appliance_type.lower() == "light":
if not app_type or app_type.lower() != "light":
continue
blocks.append(block)
unique_id = (
f'{wrapper.device.shelly["mac"]}-{block.type}_{block.channel}'
)
assert wrapper.device.shelly
unique_id = f"{wrapper.mac}-{block.type}_{block.channel}"
await async_remove_shelly_entity(hass, "switch", unique_id)
if not blocks:
return
async_add_entities(ShellyLight(wrapper, block) for block in blocks)
async_add_entities(BlockShellyLight(wrapper, block) for block in blocks)
class ShellyLight(ShellyBlockEntity, LightEntity):
"""Switch that controls a relay block on Shelly devices."""
async def async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entities for RPC device."""
wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][RPC]
def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None:
switch_keys = []
for i in range(4):
key = f"switch:{i}"
if not wrapper.device.status.get(key):
continue
con_types = wrapper.device.config["sys"]["ui_data"].get("consumption_types")
if con_types is None or con_types[i] != "lights":
continue
switch_keys.append((key, i))
unique_id = f"{wrapper.mac}-{key}"
await async_remove_shelly_entity(hass, "switch", unique_id)
if not switch_keys:
return
async_add_entities(RpcShellyLight(wrapper, key, id_) for key, id_ in switch_keys)
class BlockShellyLight(ShellyBlockEntity, LightEntity):
"""Entity that controls a light on block based Shelly devices."""
def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None:
"""Initialize light."""
super().__init__(wrapper, block)
self.control_result: dict[str, Any] | None = None
@ -369,3 +412,25 @@ class ShellyLight(ShellyBlockEntity, LightEntity):
self.control_result = None
self.mode_result = None
super()._update_callback()
class RpcShellyLight(ShellyRpcEntity, LightEntity):
"""Entity that controls a light on RPC based Shelly devices."""
def __init__(self, wrapper: RpcDeviceWrapper, key: str, id_: int) -> None:
"""Initialize light."""
super().__init__(wrapper, key)
self._id = id_
@property
def is_on(self) -> bool:
"""If light is on."""
return bool(self.wrapper.device.status[self.key]["output"])
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on light."""
await self.call_rpc("Switch.Set", {"id": self._id, "on": True})
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off light."""
await self.call_rpc("Switch.Set", {"id": self._id, "on": False})

View File

@ -7,7 +7,7 @@ from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.typing import EventType
from . import get_device_wrapper
from . import RpcDeviceWrapper, get_device_wrapper
from .const import (
ATTR_CHANNEL,
ATTR_CLICK_TYPE,
@ -29,6 +29,10 @@ def async_describe_events(
def async_describe_shelly_click_event(event: EventType) -> dict[str, str]:
"""Describe shelly.click logbook event."""
wrapper = get_device_wrapper(hass, event.data[ATTR_DEVICE_ID])
if isinstance(wrapper, RpcDeviceWrapper):
return {}
if wrapper and wrapper.device.initialized:
device_name = get_block_device_name(wrapper.device)
else:

View File

@ -10,10 +10,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ShellyDeviceWrapper
from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN
from .entity import ShellyBlockEntity
from .utils import async_remove_shelly_entity
from . import BlockDeviceWrapper, RpcDeviceWrapper
from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN, RPC
from .entity import ShellyBlockEntity, ShellyRpcEntity
from .utils import async_remove_shelly_entity, get_device_entry_gen
async def async_setup_entry(
@ -22,7 +22,19 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up switches for device."""
wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP]
if get_device_entry_gen(config_entry) == 2:
return await async_setup_rpc_entry(hass, config_entry, async_add_entities)
return await async_setup_block_entry(hass, config_entry, async_add_entities)
async def async_setup_block_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entities for block device."""
wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][BLOCK]
# In roller mode the relay blocks exist but do not contain required info
if (
@ -32,32 +44,59 @@ async def async_setup_entry(
return
relay_blocks = []
assert wrapper.device.blocks
for block in wrapper.device.blocks:
if block.type == "relay":
appliance_type = wrapper.device.settings["relays"][int(block.channel)].get(
if block.type != "relay":
continue
app_type = wrapper.device.settings["relays"][int(block.channel)].get(
"appliance_type"
)
if not appliance_type or appliance_type.lower() != "light":
if app_type and app_type.lower() == "light":
continue
relay_blocks.append(block)
unique_id = (
f'{wrapper.device.shelly["mac"]}-{block.type}_{block.channel}'
)
await async_remove_shelly_entity(
hass,
"light",
unique_id,
)
unique_id = f"{wrapper.mac}-{block.type}_{block.channel}"
await async_remove_shelly_entity(hass, "light", unique_id)
if not relay_blocks:
return
async_add_entities(RelaySwitch(wrapper, block) for block in relay_blocks)
async_add_entities(BlockRelaySwitch(wrapper, block) for block in relay_blocks)
class RelaySwitch(ShellyBlockEntity, SwitchEntity):
"""Switch that controls a relay block on Shelly devices."""
async def async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entities for RPC device."""
wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][RPC]
def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None:
switch_keys = []
for i in range(4):
key = f"switch:{i}"
if not wrapper.device.status.get(key):
continue
con_types = wrapper.device.config["sys"]["ui_data"].get("consumption_types")
if con_types is not None and con_types[i] == "lights":
continue
switch_keys.append((key, i))
unique_id = f"{wrapper.mac}-{key}"
await async_remove_shelly_entity(hass, "light", unique_id)
if not switch_keys:
return
async_add_entities(RpcRelaySwitch(wrapper, key, id_) for key, id_ in switch_keys)
class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity):
"""Entity that controls a relay on Block based Shelly devices."""
def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None:
"""Initialize relay switch."""
super().__init__(wrapper, block)
self.control_result: dict[str, Any] | None = None
@ -85,3 +124,25 @@ class RelaySwitch(ShellyBlockEntity, SwitchEntity):
"""When device updates, clear control result that overrides state."""
self.control_result = None
super()._update_callback()
class RpcRelaySwitch(ShellyRpcEntity, SwitchEntity):
"""Entity that controls a relay on RPC based Shelly devices."""
def __init__(self, wrapper: RpcDeviceWrapper, key: str, id_: int) -> None:
"""Initialize relay switch."""
super().__init__(wrapper, key)
self._id = id_
@property
def is_on(self) -> bool:
"""If switch is on."""
return bool(self.wrapper.device.status[self.key]["output"])
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on relay."""
await self.call_rpc("Switch.Set", {"id": self._id, "on": True})
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off relay."""
await self.call_rpc("Switch.Set", {"id": self._id, "on": False})

View File

@ -9,6 +9,7 @@ from aioshelly.block_device import BLOCK_VALUE_UNIT, COAP, Block, BlockDevice
from aioshelly.const import MODEL_NAMES
from aioshelly.rpc_device import RpcDevice
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, TEMP_FAHRENHEIT
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import singleton
@ -81,12 +82,12 @@ def get_number_of_channels(device: BlockDevice, block: Block) -> int:
return channels or 1
def get_entity_name(
def get_block_entity_name(
device: BlockDevice,
block: Block | None,
description: str | None = None,
) -> str:
"""Naming for switch and sensors."""
"""Naming for block based switch and sensors."""
channel_name = get_device_channel_name(device, block)
if description:
@ -237,3 +238,23 @@ def get_model_name(info: dict[str, Any]) -> str:
return cast(str, MODEL_NAMES.get(info["model"], info["model"]))
return cast(str, MODEL_NAMES.get(info["type"], info["type"]))
def get_rpc_entity_name(
device: RpcDevice, key: str, description: str | None = None
) -> str:
"""Naming for RPC based switch and sensors."""
entity_name: str | None = device.config[key].get("name")
if entity_name is None:
entity_name = f"{get_rpc_device_name(device)} {key.replace(':', '_')}"
if description:
return f"{entity_name} {description}"
return entity_name
def get_device_entry_gen(entry: ConfigEntry) -> int:
"""Return the device generation from config entry."""
return entry.data.get("gen", 1)

View File

@ -3,12 +3,13 @@ from unittest.mock import AsyncMock, Mock, patch
import pytest
from homeassistant.components.shelly import ShellyDeviceWrapper
from homeassistant.components.shelly import BlockDeviceWrapper, RpcDeviceWrapper
from homeassistant.components.shelly.const import (
COAP,
BLOCK,
DATA_CONFIG_ENTRY,
DOMAIN,
EVENT_SHELLY_CLICK,
RPC,
)
from homeassistant.setup import async_setup_component
@ -54,6 +55,13 @@ MOCK_BLOCKS = [
),
]
MOCK_CONFIG = {
"switch:0": {"name": "test switch_0"},
"sys": {"ui_data": {}},
"wifi": {
"ap": {"ssid": "Test name"},
},
}
MOCK_SHELLY = {
"mac": "test-mac",
@ -62,6 +70,10 @@ MOCK_SHELLY = {
"num_outputs": 2,
}
MOCK_STATUS = {
"switch:0": {"output": True},
}
@pytest.fixture(autouse=True)
def mock_coap():
@ -104,6 +116,7 @@ async def coap_wrapper(hass):
blocks=MOCK_BLOCKS,
settings=MOCK_SETTINGS,
shelly=MOCK_SHELLY,
firmware_version="some fw string",
update=AsyncMock(),
initialized=True,
)
@ -111,9 +124,43 @@ async def coap_wrapper(hass):
hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}}
hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {}
wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][
COAP
] = ShellyDeviceWrapper(hass, config_entry, device)
BLOCK
] = BlockDeviceWrapper(hass, config_entry, device)
await wrapper.async_setup()
wrapper.async_setup()
return wrapper
@pytest.fixture
async def rpc_wrapper(hass):
"""Setups a coap wrapper with mocked device."""
await async_setup_component(hass, "shelly", {})
config_entry = MockConfigEntry(
domain=DOMAIN,
data={"sleep_period": 0, "model": "SNSW-001P16EU", "gen": 2},
unique_id="12345678",
)
config_entry.add_to_hass(hass)
device = Mock(
call_rpc=AsyncMock(),
config=MOCK_CONFIG,
shelly=MOCK_SHELLY,
status=MOCK_STATUS,
firmware_version="some fw string",
update=AsyncMock(),
initialized=True,
shutdown=AsyncMock(),
)
hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}}
hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {}
wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][
RPC
] = RpcDeviceWrapper(hass, config_entry, device)
wrapper.async_setup()
return wrapper

View File

@ -8,11 +8,11 @@ from homeassistant.components import automation
from homeassistant.components.device_automation.exceptions import (
InvalidDeviceAutomationConfig,
)
from homeassistant.components.shelly import ShellyDeviceWrapper
from homeassistant.components.shelly import BlockDeviceWrapper
from homeassistant.components.shelly.const import (
ATTR_CHANNEL,
ATTR_CLICK_TYPE,
COAP,
BLOCK,
CONF_SUBTYPE,
DATA_CONFIG_ENTRY,
DOMAIN,
@ -79,10 +79,10 @@ async def test_get_triggers_button(hass):
hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}}
hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {}
coap_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][
COAP
] = ShellyDeviceWrapper(hass, config_entry, device)
BLOCK
] = BlockDeviceWrapper(hass, config_entry, device)
await coap_wrapper.async_setup()
coap_wrapper.async_setup()
expected_triggers = [
{

View File

@ -11,8 +11,8 @@ from homeassistant.const import (
RELAY_BLOCK_ID = 0
async def test_services(hass, coap_wrapper):
"""Test device turn on/off services."""
async def test_block_device_services(hass, coap_wrapper):
"""Test block device turn on/off services."""
assert coap_wrapper
hass.async_create_task(
@ -37,8 +37,8 @@ async def test_services(hass, coap_wrapper):
assert hass.states.get("switch.test_name_channel_1").state == STATE_OFF
async def test_update(hass, coap_wrapper, monkeypatch):
"""Test device update."""
async def test_block_device_update(hass, coap_wrapper, monkeypatch):
"""Test block device update."""
assert coap_wrapper
hass.async_create_task(
@ -61,8 +61,8 @@ async def test_update(hass, coap_wrapper, monkeypatch):
assert hass.states.get("switch.test_name_channel_1").state == STATE_ON
async def test_no_relay_blocks(hass, coap_wrapper, monkeypatch):
"""Test device without relay blocks."""
async def test_block_device_no_relay_blocks(hass, coap_wrapper, monkeypatch):
"""Test block device without relay blocks."""
assert coap_wrapper
monkeypatch.setattr(coap_wrapper.device.blocks[RELAY_BLOCK_ID], "type", "roller")
@ -73,8 +73,8 @@ async def test_no_relay_blocks(hass, coap_wrapper, monkeypatch):
assert hass.states.get("switch.test_name_channel_1") is None
async def test_device_mode_roller(hass, coap_wrapper, monkeypatch):
"""Test switch device in roller mode."""
async def test_block_device_mode_roller(hass, coap_wrapper, monkeypatch):
"""Test block device in roller mode."""
assert coap_wrapper
monkeypatch.setitem(coap_wrapper.device.settings, "mode", "roller")
@ -83,3 +83,61 @@ async def test_device_mode_roller(hass, coap_wrapper, monkeypatch):
)
await hass.async_block_till_done()
assert hass.states.get("switch.test_name_channel_1") is None
async def test_block_device_app_type_light(hass, coap_wrapper, monkeypatch):
"""Test block device in app type set to light mode."""
assert coap_wrapper
monkeypatch.setitem(
coap_wrapper.device.settings["relays"][0], "appliance_type", "light"
)
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(coap_wrapper.entry, SWITCH_DOMAIN)
)
await hass.async_block_till_done()
assert hass.states.get("switch.test_name_channel_1") is None
async def test_rpc_device_services(hass, rpc_wrapper, monkeypatch):
"""Test RPC device turn on/off services."""
assert rpc_wrapper
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, SWITCH_DOMAIN)
)
await hass.async_block_till_done()
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "switch.test_switch_0"},
blocking=True,
)
assert hass.states.get("switch.test_switch_0").state == STATE_ON
monkeypatch.setitem(rpc_wrapper.device.status["switch:0"], "output", False)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "switch.test_switch_0"},
blocking=True,
)
rpc_wrapper.async_set_updated_data("")
assert hass.states.get("switch.test_switch_0").state == STATE_OFF
async def test_rpc_device_switch_type_lights_mode(hass, rpc_wrapper, monkeypatch):
"""Test RPC device with switch in consumption type lights mode."""
assert rpc_wrapper
monkeypatch.setitem(
rpc_wrapper.device.config["sys"]["ui_data"],
"consumption_types",
["lights"],
)
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, SWITCH_DOMAIN)
)
await hass.async_block_till_done()
assert hass.states.get("switch.test_switch_0") is None