Merge pull request #56472 from home-assistant/shelly-gen2

This commit is contained in:
Paulus Schoutsen 2021-09-20 20:19:19 -07:00 committed by GitHub
commit 9059ee6604
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1527 additions and 351 deletions

View File

@ -7,6 +7,8 @@ import logging
from typing import Any, Final, cast from typing import Any, Final, cast
import aioshelly import aioshelly
from aioshelly.block_device import BlockDevice
from aioshelly.rpc_device import RpcDevice
import async_timeout import async_timeout
import voluptuous as vol import voluptuous as vol
@ -29,8 +31,9 @@ from .const import (
ATTR_CHANNEL, ATTR_CHANNEL,
ATTR_CLICK_TYPE, ATTR_CLICK_TYPE,
ATTR_DEVICE, ATTR_DEVICE,
ATTR_GENERATION,
BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, BATTERY_DEVICES_WITH_PERMANENT_CONNECTION,
COAP, BLOCK,
CONF_COAP_PORT, CONF_COAP_PORT,
DATA_CONFIG_ENTRY, DATA_CONFIG_ENTRY,
DEFAULT_COAP_PORT, DEFAULT_COAP_PORT,
@ -41,14 +44,24 @@ from .const import (
POLLING_TIMEOUT_SEC, POLLING_TIMEOUT_SEC,
REST, REST,
REST_SENSORS_UPDATE_INTERVAL, REST_SENSORS_UPDATE_INTERVAL,
RPC,
RPC_INPUTS_EVENTS_TYPES,
RPC_RECONNECT_INTERVAL,
SHBTN_MODELS, SHBTN_MODELS,
SLEEP_PERIOD_MULTIPLIER, SLEEP_PERIOD_MULTIPLIER,
UPDATE_PERIOD_MULTIPLIER, UPDATE_PERIOD_MULTIPLIER,
) )
from .utils import get_coap_context, get_device_name, get_device_sleep_period 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"] BLOCK_PLATFORMS: Final = ["binary_sensor", "cover", "light", "sensor", "switch"]
SLEEPING_PLATFORMS: Final = ["binary_sensor", "sensor"] BLOCK_SLEEPING_PLATFORMS: Final = ["binary_sensor", "sensor"]
RPC_PLATFORMS: Final = ["binary_sensor", "light", "sensor", "switch"]
_LOGGER: Final = logging.getLogger(__name__) _LOGGER: Final = logging.getLogger(__name__)
COAP_SCHEMA: Final = vol.Schema( COAP_SCHEMA: Final = vol.Schema(
@ -87,9 +100,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = {} hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = {}
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = None 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" temperature_unit = "C" if hass.config.units.is_metric else "F"
options = aioshelly.ConnectionOptions( options = aioshelly.common.ConnectionOptions(
entry.data[CONF_HOST], entry.data[CONF_HOST],
entry.data.get(CONF_USERNAME), entry.data.get(CONF_USERNAME),
entry.data.get(CONF_PASSWORD), entry.data.get(CONF_PASSWORD),
@ -98,14 +119,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coap_context = await get_coap_context(hass) coap_context = await get_coap_context(hass)
device = await aioshelly.Device.create( device = await BlockDevice.create(
aiohttp_client.async_get_clientsession(hass), aiohttp_client.async_get_clientsession(hass),
coap_context, coap_context,
options, options,
False, False,
) )
dev_reg = await device_registry.async_get_registry(hass) dev_reg = device_registry.async_get(hass)
device_entry = None device_entry = None
if entry.unique_id is not None: if entry.unique_id is not None:
device_entry = dev_reg.async_get_device( device_entry = dev_reg.async_get_device(
@ -123,22 +144,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if sleep_period is None: if sleep_period is None:
data = {**entry.data} data = {**entry.data}
data["sleep_period"] = get_device_sleep_period(device.settings) data["sleep_period"] = get_block_device_sleep_period(device.settings)
data["model"] = device.settings["device"]["type"] data["model"] = device.settings["device"]["type"]
hass.config_entries.async_update_entry(entry, data=data) 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: if sleep_period == 0:
# Not a sleeping device, finish setup # 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: try:
async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC):
await device.initialize(True) await device.initialize()
except (asyncio.TimeoutError, OSError) as err: except (asyncio.TimeoutError, OSError) as err:
raise ConfigEntryNotReady from 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: elif sleep_period is None or device_entry is None:
# Need to get sleep info or first time sleeping device setup, wait for device # 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 hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = device
@ -146,40 +167,66 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"Setup for device %s will resume when device is online", entry.title "Setup for device %s will resume when device is online", entry.title
) )
device.subscribe_updates(_async_device_online) device.subscribe_updates(_async_device_online)
await device.coap_request("s")
else: else:
# Restore sensors for sleeping device # Restore sensors for sleeping device
_LOGGER.debug("Setting up offline device %s", entry.title) _LOGGER.debug("Setting up offline block device %s", entry.title)
await async_device_setup(hass, entry, device) await async_block_device_setup(hass, entry, device)
return True return True
async def async_device_setup( async def async_block_device_setup(
hass: HomeAssistant, entry: ConfigEntry, device: aioshelly.Device hass: HomeAssistant, entry: ConfigEntry, device: BlockDevice
) -> None: ) -> 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][ device_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][
COAP BLOCK
] = ShellyDeviceWrapper(hass, entry, device) ] = BlockDeviceWrapper(hass, entry, device)
await device_wrapper.async_setup() device_wrapper.async_setup()
platforms = SLEEPING_PLATFORMS platforms = BLOCK_SLEEPING_PLATFORMS
if not entry.data.get("sleep_period"): if not entry.data.get("sleep_period"):
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][
REST REST
] = ShellyDeviceRestWrapper(hass, device) ] = ShellyDeviceRestWrapper(hass, device)
platforms = PLATFORMS platforms = BLOCK_PLATFORMS
hass.config_entries.async_setup_platforms(entry, platforms) hass.config_entries.async_setup_platforms(entry, platforms)
class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): async def async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Wrapper for a Shelly device with Home Assistant specific functions.""" """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__( def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, device: aioshelly.Device self, hass: HomeAssistant, entry: ConfigEntry, device: BlockDevice
) -> None: ) -> None:
"""Initialize the Shelly device wrapper.""" """Initialize the Shelly device wrapper."""
self.device_id: str | None = None self.device_id: str | None = None
@ -192,7 +239,9 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
UPDATE_PERIOD_MULTIPLIER * device.settings["coiot"]["update_period"] UPDATE_PERIOD_MULTIPLIER * device.settings["coiot"]["update_period"]
) )
device_name = get_device_name(device) if device.initialized else entry.title device_name = (
get_block_device_name(device) if device.initialized else entry.title
)
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
@ -203,12 +252,14 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
self.entry = entry self.entry = entry
self.device = device self.device = device
self._async_remove_device_updates_handler = self.async_add_listener( entry.async_on_unload(
self._async_device_updates_handler self.async_add_listener(self._async_device_updates_handler)
) )
self._last_input_events_count: dict = {} self._last_input_events_count: dict = {}
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop)
)
@callback @callback
def _async_device_updates_handler(self) -> None: def _async_device_updates_handler(self) -> None:
@ -216,6 +267,8 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
if not self.device.initialized: if not self.device.initialized:
return return
assert self.device.blocks
# For buttons which are battery powered - set initial value for last_event_count # For buttons which are battery powered - set initial value for last_event_count
if self.model in SHBTN_MODELS and self._last_input_events_count.get(1) is None: if self.model in SHBTN_MODELS and self._last_input_events_count.get(1) is None:
for block in self.device.blocks: for block in self.device.blocks:
@ -255,6 +308,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
ATTR_DEVICE: self.device.settings["device"]["hostname"], ATTR_DEVICE: self.device.settings["device"]["hostname"],
ATTR_CHANNEL: channel, ATTR_CHANNEL: channel,
ATTR_CLICK_TYPE: INPUTS_EVENTS_DICT[event_type], ATTR_CLICK_TYPE: INPUTS_EVENTS_DICT[event_type],
ATTR_GENERATION: 1,
}, },
) )
else: else:
@ -270,7 +324,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
# Sleeping device, no point polling it, just mark it unavailable # Sleeping device, no point polling it, just mark it unavailable
raise update_coordinator.UpdateFailed("Sleeping device did not update") 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: try:
async with async_timeout.timeout(POLLING_TIMEOUT_SEC): async with async_timeout.timeout(POLLING_TIMEOUT_SEC):
await self.device.update() await self.device.update()
@ -287,18 +341,16 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
"""Mac address of the device.""" """Mac address of the device."""
return cast(str, self.entry.unique_id) return cast(str, self.entry.unique_id)
async def async_setup(self) -> None: def async_setup(self) -> None:
"""Set up the wrapper.""" """Set up the wrapper."""
dev_reg = await device_registry.async_get_registry(self.hass) dev_reg = device_registry.async_get(self.hass)
sw_version = self.device.settings["fw"] if self.device.initialized else "" sw_version = self.device.firmware_version if self.device.initialized else ""
entry = dev_reg.async_get_or_create( entry = dev_reg.async_get_or_create(
config_entry_id=self.entry.entry_id, config_entry_id=self.entry.entry_id,
name=self.name, name=self.name,
connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac)}, connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac)},
# This is duplicate but otherwise via_device can't work
identifiers={(DOMAIN, self.mac)},
manufacturer="Shelly", manufacturer="Shelly",
model=aioshelly.MODEL_NAMES.get(self.model, self.model), model=aioshelly.const.MODEL_NAMES.get(self.model, self.model),
sw_version=sw_version, sw_version=sw_version,
) )
self.device_id = entry.id self.device_id = entry.id
@ -306,22 +358,19 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
def shutdown(self) -> None: def shutdown(self) -> None:
"""Shutdown the wrapper.""" """Shutdown the wrapper."""
if self.device: self.device.shutdown()
self.device.shutdown()
self._async_remove_device_updates_handler()
self.device = None
@callback @callback
def _handle_ha_stop(self, _event: Event) -> None: def _handle_ha_stop(self, _event: Event) -> None:
"""Handle Home Assistant stopping.""" """Handle Home Assistant stopping."""
_LOGGER.debug("Stopping ShellyDeviceWrapper for %s", self.name) _LOGGER.debug("Stopping BlockDeviceWrapper for %s", self.name)
self.shutdown() self.shutdown()
class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator): class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator):
"""Rest Wrapper for a Shelly device with Home Assistant specific functions.""" """Rest Wrapper for a Shelly device with Home Assistant specific functions."""
def __init__(self, hass: HomeAssistant, device: aioshelly.Device) -> None: def __init__(self, hass: HomeAssistant, device: BlockDevice) -> None:
"""Initialize the Shelly device wrapper.""" """Initialize the Shelly device wrapper."""
if ( if (
device.settings["device"]["type"] device.settings["device"]["type"]
@ -336,7 +385,7 @@ class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator):
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
name=get_device_name(device), name=get_block_device_name(device),
update_interval=timedelta(seconds=update_interval), update_interval=timedelta(seconds=update_interval),
) )
self.device = device self.device = device
@ -358,39 +407,171 @@ class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator):
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
if get_device_entry_gen(entry) == 2:
unload_ok = await hass.config_entries.async_unload_platforms(
entry, RPC_PLATFORMS
)
if unload_ok:
await 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) device = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id].get(DEVICE)
if device is not None: if device is not None:
# If device is present, device wrapper is not setup yet # If device is present, device wrapper is not setup yet
device.shutdown() device.shutdown()
return True return True
platforms = SLEEPING_PLATFORMS platforms = BLOCK_SLEEPING_PLATFORMS
if not entry.data.get("sleep_period"): if not entry.data.get("sleep_period"):
hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][REST] = None 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) unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms)
if unload_ok: 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) hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id)
return unload_ok return unload_ok
def get_device_wrapper( def get_block_device_wrapper(
hass: HomeAssistant, device_id: str hass: HomeAssistant, device_id: str
) -> ShellyDeviceWrapper | None: ) -> BlockDeviceWrapper | None:
"""Get a Shelly device wrapper for the given device id.""" """Get a Shelly block device wrapper for the given device id."""
if not hass.data.get(DOMAIN): if not hass.data.get(DOMAIN):
return None return None
for config_entry in hass.data[DOMAIN][DATA_CONFIG_ENTRY]: dev_reg = device_registry.async_get(hass)
wrapper: ShellyDeviceWrapper | None = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ if device := dev_reg.async_get(device_id):
config_entry for config_entry in device.config_entries:
].get(COAP) if not hass.data[DOMAIN][DATA_CONFIG_ENTRY].get(config_entry):
continue
if wrapper and wrapper.device_id == device_id: if wrapper := hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry].get(BLOCK):
return wrapper return cast(BlockDeviceWrapper, wrapper)
return None return None
def get_rpc_device_wrapper(
hass: HomeAssistant, device_id: str
) -> RpcDeviceWrapper | None:
"""Get a Shelly RPC device wrapper for the given device id."""
if not hass.data.get(DOMAIN):
return None
dev_reg = device_registry.async_get(hass)
if device := dev_reg.async_get(device_id):
for config_entry in device.config_entries:
if not hass.data[DOMAIN][DATA_CONFIG_ENTRY].get(config_entry):
continue
if wrapper := hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry].get(RPC):
return cast(RpcDeviceWrapper, 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(
self.async_add_listener(self._async_device_updates_handler)
)
self._last_event: dict[str, Any] | None = None
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop)
)
@callback
def _async_device_updates_handler(self) -> None:
"""Handle device updates."""
if (
not self.device.initialized
or not self.device.event
or self.device.event == self._last_event
):
return
self._last_event = self.device.event
for event in self.device.event["events"]:
if event.get("event") not in RPC_INPUTS_EVENTS_TYPES:
continue
self.hass.bus.async_fire(
EVENT_SHELLY_CLICK,
{
ATTR_DEVICE_ID: self.device_id,
ATTR_DEVICE: self.device.hostname,
ATTR_CHANNEL: event["id"] + 1,
ATTR_CLICK_TYPE: event["event"],
ATTR_GENERATION: 2,
},
)
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

@ -24,13 +24,20 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .entity import ( from .entity import (
BlockAttributeDescription, BlockAttributeDescription,
RestAttributeDescription, RestAttributeDescription,
RpcAttributeDescription,
ShellyBlockAttributeEntity, ShellyBlockAttributeEntity,
ShellyRestAttributeEntity, ShellyRestAttributeEntity,
ShellyRpcAttributeEntity,
ShellySleepingBlockAttributeEntity, ShellySleepingBlockAttributeEntity,
async_setup_entry_attribute_entities, async_setup_entry_attribute_entities,
async_setup_entry_rest, async_setup_entry_rest,
async_setup_entry_rpc,
)
from .utils import (
get_device_entry_gen,
is_block_momentary_input,
is_rpc_momentary_input,
) )
from .utils import is_momentary_input
SENSORS: Final = { SENSORS: Final = {
("device", "overtemp"): BlockAttributeDescription( ("device", "overtemp"): BlockAttributeDescription(
@ -48,7 +55,7 @@ SENSORS: Final = {
("sensor", "dwIsOpened"): BlockAttributeDescription( ("sensor", "dwIsOpened"): BlockAttributeDescription(
name="Door", name="Door",
device_class=DEVICE_CLASS_OPENING, device_class=DEVICE_CLASS_OPENING,
available=lambda block: cast(bool, block.dwIsOpened != -1), available=lambda block: cast(int, block.dwIsOpened) != -1,
), ),
("sensor", "flood"): BlockAttributeDescription( ("sensor", "flood"): BlockAttributeDescription(
name="Flood", device_class=DEVICE_CLASS_MOISTURE name="Flood", device_class=DEVICE_CLASS_MOISTURE
@ -69,19 +76,19 @@ SENSORS: Final = {
name="Input", name="Input",
device_class=DEVICE_CLASS_POWER, device_class=DEVICE_CLASS_POWER,
default_enabled=False, default_enabled=False,
removal_condition=is_momentary_input, removal_condition=is_block_momentary_input,
), ),
("relay", "input"): BlockAttributeDescription( ("relay", "input"): BlockAttributeDescription(
name="Input", name="Input",
device_class=DEVICE_CLASS_POWER, device_class=DEVICE_CLASS_POWER,
default_enabled=False, default_enabled=False,
removal_condition=is_momentary_input, removal_condition=is_block_momentary_input,
), ),
("device", "input"): BlockAttributeDescription( ("device", "input"): BlockAttributeDescription(
name="Input", name="Input",
device_class=DEVICE_CLASS_POWER, device_class=DEVICE_CLASS_POWER,
default_enabled=False, default_enabled=False,
removal_condition=is_momentary_input, removal_condition=is_block_momentary_input,
), ),
("sensor", "extInput"): BlockAttributeDescription( ("sensor", "extInput"): BlockAttributeDescription(
name="External Input", name="External Input",
@ -112,6 +119,41 @@ REST_SENSORS: Final = {
), ),
} }
RPC_SENSORS: Final = {
"input": RpcAttributeDescription(
key="input",
name="Input",
value=lambda status, _: status["state"],
device_class=DEVICE_CLASS_POWER,
default_enabled=False,
removal_condition=is_rpc_momentary_input,
),
"cloud": RpcAttributeDescription(
key="cloud",
name="Cloud",
value=lambda status, _: status["connected"],
device_class=DEVICE_CLASS_CONNECTIVITY,
default_enabled=False,
),
"fwupdate": RpcAttributeDescription(
key="sys",
name="Firmware Update",
device_class=DEVICE_CLASS_UPDATE,
value=lambda status, _: status["available_updates"],
default_enabled=False,
extra_state_attributes=lambda status: {
"latest_stable_version": status["available_updates"].get(
"stable",
{"version": ""},
)["version"],
"beta_version": status["available_updates"].get(
"beta",
{"version": ""},
)["version"],
},
),
}
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -119,29 +161,34 @@ 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:
return await async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_SENSORS, RpcBinarySensor
)
if config_entry.data["sleep_period"]: if config_entry.data["sleep_period"]:
await async_setup_entry_attribute_entities( await async_setup_entry_attribute_entities(
hass, hass,
config_entry, config_entry,
async_add_entities, async_add_entities,
SENSORS, SENSORS,
ShellySleepingBinarySensor, BlockSleepingBinarySensor,
) )
else: else:
await async_setup_entry_attribute_entities( await async_setup_entry_attribute_entities(
hass, config_entry, async_add_entities, SENSORS, ShellyBinarySensor hass, config_entry, async_add_entities, SENSORS, BlockBinarySensor
) )
await async_setup_entry_rest( await async_setup_entry_rest(
hass, hass,
config_entry, config_entry,
async_add_entities, async_add_entities,
REST_SENSORS, REST_SENSORS,
ShellyRestBinarySensor, RestBinarySensor,
) )
class ShellyBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity): class BlockBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity):
"""Shelly binary sensor entity.""" """Represent a block binary sensor entity."""
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
@ -149,8 +196,8 @@ class ShellyBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity):
return bool(self.attribute_value) return bool(self.attribute_value)
class ShellyRestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity): class RestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity):
"""Shelly REST binary sensor entity.""" """Represent a REST binary sensor entity."""
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
@ -158,10 +205,17 @@ class ShellyRestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity):
return bool(self.attribute_value) return bool(self.attribute_value)
class ShellySleepingBinarySensor( class RpcBinarySensor(ShellyRpcAttributeEntity, BinarySensorEntity):
ShellySleepingBlockAttributeEntity, BinarySensorEntity """Represent a RPC binary sensor entity."""
):
"""Represent a shelly sleeping binary sensor.""" @property
def is_on(self) -> bool:
"""Return true if RPC sensor state is on."""
return bool(self.attribute_value)
class BlockSleepingBinarySensor(ShellySleepingBlockAttributeEntity, BinarySensorEntity):
"""Represent a block sleeping binary sensor."""
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:

View File

@ -3,26 +3,37 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from typing import Any, Dict, Final, cast from typing import Any, Final
import aiohttp import aiohttp
import aioshelly import aioshelly
from aioshelly.block_device import BlockDevice
from aioshelly.rpc_device import RpcDevice
import async_timeout import async_timeout
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries, core from homeassistant import config_entries
from homeassistant.const import ( from homeassistant.const import (
CONF_HOST, CONF_HOST,
CONF_PASSWORD, CONF_PASSWORD,
CONF_USERNAME, CONF_USERNAME,
HTTP_UNAUTHORIZED, HTTP_UNAUTHORIZED,
) )
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.helpers.typing import DiscoveryInfoType
from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, DOMAIN from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, DOMAIN
from .utils import get_coap_context, get_device_sleep_period from .utils import (
get_block_device_name,
get_block_device_sleep_period,
get_coap_context,
get_info_auth,
get_info_gen,
get_model_name,
get_rpc_device_name,
)
_LOGGER: Final = logging.getLogger(__name__) _LOGGER: Final = logging.getLogger(__name__)
@ -32,34 +43,49 @@ HTTP_CONNECT_ERRORS: Final = (asyncio.TimeoutError, aiohttp.ClientError)
async def validate_input( async def validate_input(
hass: core.HomeAssistant, host: str, data: dict[str, Any] hass: HomeAssistant,
host: str,
info: dict[str, Any],
data: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Validate the user input allows us to connect. """Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user. Data has the keys from HOST_SCHEMA with values provided by the user.
""" """
options = aioshelly.common.ConnectionOptions(
options = aioshelly.ConnectionOptions( host,
host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD) data.get(CONF_USERNAME),
data.get(CONF_PASSWORD),
) )
coap_context = await get_coap_context(hass)
async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC):
device = await aioshelly.Device.create( if get_info_gen(info) == 2:
rpc_device = await RpcDevice.create(
aiohttp_client.async_get_clientsession(hass),
options,
)
await rpc_device.shutdown()
return {
"title": get_rpc_device_name(rpc_device),
"sleep_period": 0,
"model": rpc_device.model,
"gen": 2,
}
# Gen1
coap_context = await get_coap_context(hass)
block_device = await BlockDevice.create(
aiohttp_client.async_get_clientsession(hass), aiohttp_client.async_get_clientsession(hass),
coap_context, coap_context,
options, options,
) )
block_device.shutdown()
device.shutdown() return {
"title": get_block_device_name(block_device),
# Return info that you want to store in the config entry. "sleep_period": get_block_device_sleep_period(block_device.settings),
return { "model": block_device.model,
"title": device.settings["name"], "gen": 1,
"hostname": device.settings["device"]["hostname"], }
"sleep_period": get_device_sleep_period(device.settings),
"model": device.settings["device"]["type"],
}
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@ -79,23 +105,25 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None: if user_input is not None:
host: str = user_input[CONF_HOST] host: str = user_input[CONF_HOST]
try: try:
info = await self._async_get_info(host) self.info = await self._async_get_info(host)
except HTTP_CONNECT_ERRORS: except HTTP_CONNECT_ERRORS:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except aioshelly.FirmwareUnsupported: except aioshelly.exceptions.FirmwareUnsupported:
return self.async_abort(reason="unsupported_firmware") return self.async_abort(reason="unsupported_firmware")
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
await self.async_set_unique_id(info["mac"]) await self.async_set_unique_id(self.info["mac"])
self._abort_if_unique_id_configured({CONF_HOST: host}) self._abort_if_unique_id_configured({CONF_HOST: host})
self.host = host self.host = host
if info["auth"]: if get_info_auth(self.info):
return await self.async_step_credentials() return await self.async_step_credentials()
try: try:
device_info = await validate_input(self.hass, self.host, {}) device_info = await validate_input(
self.hass, self.host, self.info, {}
)
except HTTP_CONNECT_ERRORS: except HTTP_CONNECT_ERRORS:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
@ -103,11 +131,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
return self.async_create_entry( return self.async_create_entry(
title=device_info["title"] or device_info["hostname"], title=device_info["title"],
data={ data={
**user_input, **user_input,
"sleep_period": device_info["sleep_period"], "sleep_period": device_info["sleep_period"],
"model": device_info["model"], "model": device_info["model"],
"gen": device_info["gen"],
}, },
) )
@ -122,7 +151,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
try: try:
device_info = await validate_input(self.hass, self.host, user_input) device_info = await validate_input(
self.hass, self.host, self.info, user_input
)
except aiohttp.ClientResponseError as error: except aiohttp.ClientResponseError as error:
if error.status == HTTP_UNAUTHORIZED: if error.status == HTTP_UNAUTHORIZED:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
@ -135,12 +166,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
return self.async_create_entry( return self.async_create_entry(
title=device_info["title"] or device_info["hostname"], title=device_info["title"],
data={ data={
**user_input, **user_input,
CONF_HOST: self.host, CONF_HOST: self.host,
"sleep_period": device_info["sleep_period"], "sleep_period": device_info["sleep_period"],
"model": device_info["model"], "model": device_info["model"],
"gen": device_info["gen"],
}, },
) )
else: else:
@ -162,13 +194,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) -> FlowResult: ) -> FlowResult:
"""Handle zeroconf discovery.""" """Handle zeroconf discovery."""
try: try:
self.info = info = await self._async_get_info(discovery_info["host"]) self.info = await self._async_get_info(discovery_info["host"])
except HTTP_CONNECT_ERRORS: except HTTP_CONNECT_ERRORS:
return self.async_abort(reason="cannot_connect") return self.async_abort(reason="cannot_connect")
except aioshelly.FirmwareUnsupported: except aioshelly.exceptions.FirmwareUnsupported:
return self.async_abort(reason="unsupported_firmware") return self.async_abort(reason="unsupported_firmware")
await self.async_set_unique_id(info["mac"]) await self.async_set_unique_id(self.info["mac"])
self._abort_if_unique_id_configured({CONF_HOST: discovery_info["host"]}) self._abort_if_unique_id_configured({CONF_HOST: discovery_info["host"]})
self.host = discovery_info["host"] self.host = discovery_info["host"]
@ -176,11 +208,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"name": discovery_info.get("name", "").split(".")[0] "name": discovery_info.get("name", "").split(".")[0]
} }
if info["auth"]: if get_info_auth(self.info):
return await self.async_step_credentials() return await self.async_step_credentials()
try: try:
self.device_info = await validate_input(self.hass, self.host, {}) self.device_info = await validate_input(self.hass, self.host, self.info, {})
except HTTP_CONNECT_ERRORS: except HTTP_CONNECT_ERRORS:
return self.async_abort(reason="cannot_connect") return self.async_abort(reason="cannot_connect")
@ -193,11 +225,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
return self.async_create_entry( return self.async_create_entry(
title=self.device_info["title"] or self.device_info["hostname"], title=self.device_info["title"],
data={ data={
"host": self.host, "host": self.host,
"sleep_period": self.device_info["sleep_period"], "sleep_period": self.device_info["sleep_period"],
"model": self.device_info["model"], "model": self.device_info["model"],
"gen": self.device_info["gen"],
}, },
) )
@ -206,9 +239,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_show_form( return self.async_show_form(
step_id="confirm_discovery", step_id="confirm_discovery",
description_placeholders={ description_placeholders={
"model": aioshelly.MODEL_NAMES.get( "model": get_model_name(self.info),
self.info["type"], self.info["type"]
),
"host": self.host, "host": self.host,
}, },
errors=errors, errors=errors,
@ -217,10 +248,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def _async_get_info(self, host: str) -> dict[str, Any]: async def _async_get_info(self, host: str) -> dict[str, Any]:
"""Get info from shelly device.""" """Get info from shelly device."""
async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC):
return cast( return await aioshelly.common.get_info(
Dict[str, Any], aiohttp_client.async_get_clientsession(self.hass), host
await aioshelly.get_info(
aiohttp_client.async_get_clientsession(self.hass),
host,
),
) )

View File

@ -4,11 +4,12 @@ from __future__ import annotations
import re import re
from typing import Final from typing import Final
COAP: Final = "coap" BLOCK: Final = "block"
DATA_CONFIG_ENTRY: Final = "config_entry" DATA_CONFIG_ENTRY: Final = "config_entry"
DEVICE: Final = "device" DEVICE: Final = "device"
DOMAIN: Final = "shelly" DOMAIN: Final = "shelly"
REST: Final = "rest" REST: Final = "rest"
RPC: Final = "rpc"
CONF_COAP_PORT: Final = "coap_port" CONF_COAP_PORT: Final = "coap_port"
DEFAULT_COAP_PORT: Final = 5683 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. # Multiplier used to calculate the "update_interval" for non-sleeping devices.
UPDATE_PERIOD_MULTIPLIER: Final = 2.2 UPDATE_PERIOD_MULTIPLIER: Final = 2.2
# Reconnect interval for GEN2 devices
RPC_RECONNECT_INTERVAL = 60
# Shelly Air - Maximum work hours before lamp replacement # Shelly Air - Maximum work hours before lamp replacement
SHAIR_MAX_WORK_HOURS: Final = 9000 SHAIR_MAX_WORK_HOURS: Final = 9000
@ -60,18 +64,28 @@ INPUTS_EVENTS_DICT: Final = {
# List of battery devices that maintain a permanent WiFi connection # List of battery devices that maintain a permanent WiFi connection
BATTERY_DEVICES_WITH_PERMANENT_CONNECTION: Final = ["SHMOS-01"] BATTERY_DEVICES_WITH_PERMANENT_CONNECTION: Final = ["SHMOS-01"]
# Button/Click events for Block & RPC devices
EVENT_SHELLY_CLICK: Final = "shelly.click" EVENT_SHELLY_CLICK: Final = "shelly.click"
ATTR_CLICK_TYPE: Final = "click_type" ATTR_CLICK_TYPE: Final = "click_type"
ATTR_CHANNEL: Final = "channel" ATTR_CHANNEL: Final = "channel"
ATTR_DEVICE: Final = "device" ATTR_DEVICE: Final = "device"
ATTR_GENERATION: Final = "generation"
CONF_SUBTYPE: Final = "subtype" CONF_SUBTYPE: Final = "subtype"
BASIC_INPUTS_EVENTS_TYPES: Final = {"single", "long"} BASIC_INPUTS_EVENTS_TYPES: Final = {"single", "long"}
SHBTN_INPUTS_EVENTS_TYPES: Final = {"single", "double", "triple", "long"} SHBTN_INPUTS_EVENTS_TYPES: Final = {"single", "double", "triple", "long"}
SUPPORTED_INPUTS_EVENTS_TYPES: Final = { RPC_INPUTS_EVENTS_TYPES: Final = {
"btn_down",
"btn_up",
"single_push",
"double_push",
"long_push",
}
BLOCK_INPUTS_EVENTS_TYPES: Final = {
"single", "single",
"double", "double",
"triple", "triple",
@ -80,9 +94,15 @@ SUPPORTED_INPUTS_EVENTS_TYPES: Final = {
"long_single", "long_single",
} }
SHIX3_1_INPUTS_EVENTS_TYPES = SUPPORTED_INPUTS_EVENTS_TYPES SHIX3_1_INPUTS_EVENTS_TYPES = BLOCK_INPUTS_EVENTS_TYPES
INPUTS_EVENTS_SUBTYPES: Final = {"button": 1, "button1": 1, "button2": 2, "button3": 3} INPUTS_EVENTS_SUBTYPES: Final = {
"button": 1,
"button1": 1,
"button2": 2,
"button3": 3,
"button4": 4,
}
SHBTN_MODELS: Final = ["SHBTN-1", "SHBTN-2"] SHBTN_MODELS: Final = ["SHBTN-1", "SHBTN-2"]
@ -109,3 +129,6 @@ KELVIN_MIN_VALUE_WHITE: Final = 2700
KELVIN_MIN_VALUE_COLOR: Final = 3000 KELVIN_MIN_VALUE_COLOR: Final = 3000
UPTIME_DEVIATION: Final = 5 UPTIME_DEVIATION: Final = 5
# Max RPC switch/input key instances
MAX_RPC_KEY_INSTANCES = 4

View File

@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Any, cast from typing import Any, cast
from aioshelly import Block from aioshelly.block_device import Block
from homeassistant.components.cover import ( from homeassistant.components.cover import (
ATTR_POSITION, ATTR_POSITION,
@ -18,8 +18,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ShellyDeviceWrapper from . import BlockDeviceWrapper
from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN
from .entity import ShellyBlockEntity from .entity import ShellyBlockEntity
@ -29,7 +29,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up cover for device.""" """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"] blocks = [block for block in wrapper.device.blocks if block.type == "roller"]
if not blocks: if not blocks:
@ -43,7 +43,7 @@ class ShellyCover(ShellyBlockEntity, CoverEntity):
_attr_device_class = DEVICE_CLASS_SHUTTER _attr_device_class = DEVICE_CLASS_SHUTTER
def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None:
"""Initialize light.""" """Initialize light."""
super().__init__(wrapper, block) super().__init__(wrapper, block)
self.control_result: dict[str, Any] | None = None self.control_result: dict[str, Any] | None = None
@ -57,7 +57,7 @@ class ShellyCover(ShellyBlockEntity, CoverEntity):
if self.control_result: if self.control_result:
return cast(bool, self.control_result["current_pos"] == 0) return cast(bool, self.control_result["current_pos"] == 0)
return cast(bool, self.block.rollerPos == 0) return cast(int, self.block.rollerPos) == 0
@property @property
def current_cover_position(self) -> int: def current_cover_position(self) -> int:
@ -73,7 +73,7 @@ class ShellyCover(ShellyBlockEntity, CoverEntity):
if self.control_result: if self.control_result:
return cast(bool, self.control_result["state"] == "close") return cast(bool, self.control_result["state"] == "close")
return cast(bool, self.block.roller == "close") return self.block.roller == "close"
@property @property
def is_opening(self) -> bool: def is_opening(self) -> bool:
@ -81,7 +81,7 @@ class ShellyCover(ShellyBlockEntity, CoverEntity):
if self.control_result: if self.control_result:
return cast(bool, self.control_result["state"] == "open") return cast(bool, self.control_result["state"] == "open")
return cast(bool, self.block.roller == "open") return self.block.roller == "open"
@property @property
def supported_features(self) -> int: def supported_features(self) -> int:

View File

@ -25,28 +25,52 @@ from homeassistant.const import (
from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from . import get_device_wrapper from . import get_block_device_wrapper, get_rpc_device_wrapper
from .const import ( from .const import (
ATTR_CHANNEL, ATTR_CHANNEL,
ATTR_CLICK_TYPE, ATTR_CLICK_TYPE,
BLOCK_INPUTS_EVENTS_TYPES,
CONF_SUBTYPE, CONF_SUBTYPE,
DOMAIN, DOMAIN,
EVENT_SHELLY_CLICK, EVENT_SHELLY_CLICK,
INPUTS_EVENTS_SUBTYPES, INPUTS_EVENTS_SUBTYPES,
SHBTN_INPUTS_EVENTS_TYPES, RPC_INPUTS_EVENTS_TYPES,
SHBTN_MODELS, SHBTN_MODELS,
SUPPORTED_INPUTS_EVENTS_TYPES,
) )
from .utils import get_input_triggers from .utils import (
get_block_input_triggers,
get_rpc_input_triggers,
get_shbtn_input_triggers,
)
TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend(
{ {
vol.Required(CONF_TYPE): vol.In(SUPPORTED_INPUTS_EVENTS_TYPES), vol.Required(CONF_TYPE): vol.In(
RPC_INPUTS_EVENTS_TYPES | BLOCK_INPUTS_EVENTS_TYPES
),
vol.Required(CONF_SUBTYPE): vol.In(INPUTS_EVENTS_SUBTYPES), vol.Required(CONF_SUBTYPE): vol.In(INPUTS_EVENTS_SUBTYPES),
} }
) )
def append_input_triggers(
triggers: list[dict[str, Any]],
input_triggers: list[tuple[str, str]],
device_id: str,
) -> None:
"""Add trigger to triggers list."""
for trigger, subtype in input_triggers:
triggers.append(
{
CONF_PLATFORM: "device",
CONF_DEVICE_ID: device_id,
CONF_DOMAIN: DOMAIN,
CONF_TYPE: trigger,
CONF_SUBTYPE: subtype,
}
)
async def async_validate_trigger_config( async def async_validate_trigger_config(
hass: HomeAssistant, config: dict[str, Any] hass: HomeAssistant, config: dict[str, Any]
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -54,17 +78,29 @@ async def async_validate_trigger_config(
config = TRIGGER_SCHEMA(config) config = TRIGGER_SCHEMA(config)
# if device is available verify parameters against device capabilities # if device is available verify parameters against device capabilities
wrapper = get_device_wrapper(hass, config[CONF_DEVICE_ID])
if not wrapper or not wrapper.device.initialized:
return config
trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) trigger = (config[CONF_TYPE], config[CONF_SUBTYPE])
for block in wrapper.device.blocks: if config[CONF_TYPE] in RPC_INPUTS_EVENTS_TYPES:
input_triggers = get_input_triggers(wrapper.device, block) rpc_wrapper = get_rpc_device_wrapper(hass, config[CONF_DEVICE_ID])
if not rpc_wrapper or not rpc_wrapper.device.initialized:
return config
input_triggers = get_rpc_input_triggers(rpc_wrapper.device)
if trigger in input_triggers: if trigger in input_triggers:
return config return config
elif config[CONF_TYPE] in BLOCK_INPUTS_EVENTS_TYPES:
block_wrapper = get_block_device_wrapper(hass, config[CONF_DEVICE_ID])
if not block_wrapper or not block_wrapper.device.initialized:
return config
assert block_wrapper.device.blocks
for block in block_wrapper.device.blocks:
input_triggers = get_block_input_triggers(block_wrapper.device, block)
if trigger in input_triggers:
return config
raise InvalidDeviceAutomationConfig( raise InvalidDeviceAutomationConfig(
f"Invalid ({CONF_TYPE},{CONF_SUBTYPE}): {trigger}" f"Invalid ({CONF_TYPE},{CONF_SUBTYPE}): {trigger}"
) )
@ -74,40 +110,28 @@ async def async_get_triggers(
hass: HomeAssistant, device_id: str hass: HomeAssistant, device_id: str
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""List device triggers for Shelly devices.""" """List device triggers for Shelly devices."""
triggers = [] triggers: list[dict[str, Any]] = []
wrapper = get_device_wrapper(hass, device_id) if rpc_wrapper := get_rpc_device_wrapper(hass, device_id):
if not wrapper: input_triggers = get_rpc_input_triggers(rpc_wrapper.device)
raise InvalidDeviceAutomationConfig(f"Device not found: {device_id}") append_input_triggers(triggers, input_triggers, device_id)
if wrapper.model in SHBTN_MODELS:
for trigger in SHBTN_INPUTS_EVENTS_TYPES:
triggers.append(
{
CONF_PLATFORM: "device",
CONF_DEVICE_ID: device_id,
CONF_DOMAIN: DOMAIN,
CONF_TYPE: trigger,
CONF_SUBTYPE: "button",
}
)
return triggers return triggers
for block in wrapper.device.blocks: if block_wrapper := get_block_device_wrapper(hass, device_id):
input_triggers = get_input_triggers(wrapper.device, block) if block_wrapper.model in SHBTN_MODELS:
input_triggers = get_shbtn_input_triggers()
append_input_triggers(triggers, input_triggers, device_id)
return triggers
for trigger, subtype in input_triggers: assert block_wrapper.device.blocks
triggers.append(
{
CONF_PLATFORM: "device",
CONF_DEVICE_ID: device_id,
CONF_DOMAIN: DOMAIN,
CONF_TYPE: trigger,
CONF_SUBTYPE: subtype,
}
)
return triggers for block in block_wrapper.device.blocks:
input_triggers = get_block_input_triggers(block_wrapper.device, block)
append_input_triggers(triggers, input_triggers, device_id)
return triggers
raise InvalidDeviceAutomationConfig(f"Device not found: {device_id}")
async def async_attach_trigger( async def async_attach_trigger(
@ -126,6 +150,7 @@ async def async_attach_trigger(
ATTR_CLICK_TYPE: config[CONF_TYPE], ATTR_CLICK_TYPE: config[CONF_TYPE],
}, },
} }
event_config = event_trigger.TRIGGER_SCHEMA(event_config) event_config = event_trigger.TRIGGER_SCHEMA(event_config)
return await event_trigger.async_attach_trigger( return await event_trigger.async_attach_trigger(
hass, event_config, action, automation_info, platform_type="device" hass, event_config, action, automation_info, platform_type="device"

View File

@ -6,7 +6,7 @@ from dataclasses import dataclass
import logging import logging
from typing import Any, Callable, Final, cast from typing import Any, Callable, Final, cast
import aioshelly from aioshelly.block_device import Block
import async_timeout import async_timeout
from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.components.sensor import ATTR_STATE_CLASS
@ -23,9 +23,21 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from . import ShellyDeviceRestWrapper, ShellyDeviceWrapper from . import BlockDeviceWrapper, RpcDeviceWrapper, ShellyDeviceRestWrapper
from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, COAP, DATA_CONFIG_ENTRY, DOMAIN, REST from .const import (
from .utils import async_remove_shelly_entity, get_entity_name AIOSHELLY_DEVICE_TIMEOUT_SEC,
BLOCK,
DATA_CONFIG_ENTRY,
DOMAIN,
REST,
RPC,
)
from .utils import (
async_remove_shelly_entity,
get_block_entity_name,
get_rpc_entity_name,
get_rpc_key_instances,
)
_LOGGER: Final = logging.getLogger(__name__) _LOGGER: Final = logging.getLogger(__name__)
@ -38,9 +50,9 @@ async def async_setup_entry_attribute_entities(
sensor_class: Callable, sensor_class: Callable,
) -> None: ) -> None:
"""Set up entities for attributes.""" """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 config_entry.entry_id
][COAP] ][BLOCK]
if wrapper.device.initialized: if wrapper.device.initialized:
await async_setup_block_attribute_entities( await async_setup_block_attribute_entities(
@ -55,13 +67,15 @@ async def async_setup_entry_attribute_entities(
async def async_setup_block_attribute_entities( async def async_setup_block_attribute_entities(
hass: HomeAssistant, hass: HomeAssistant,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
wrapper: ShellyDeviceWrapper, wrapper: BlockDeviceWrapper,
sensors: dict[tuple[str, str], BlockAttributeDescription], sensors: dict[tuple[str, str], BlockAttributeDescription],
sensor_class: Callable, sensor_class: Callable,
) -> None: ) -> None:
"""Set up entities for block attributes.""" """Set up entities for block attributes."""
blocks = [] blocks = []
assert wrapper.device.blocks
for block in wrapper.device.blocks: for block in wrapper.device.blocks:
for sensor_id in block.sensor_ids: for sensor_id in block.sensor_ids:
description = sensors.get((block.type, sensor_id)) description = sensors.get((block.type, sensor_id))
@ -97,7 +111,7 @@ async def async_restore_block_attribute_entities(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
wrapper: ShellyDeviceWrapper, wrapper: BlockDeviceWrapper,
sensors: dict[tuple[str, str], BlockAttributeDescription], sensors: dict[tuple[str, str], BlockAttributeDescription],
sensor_class: Callable, sensor_class: Callable,
) -> None: ) -> None:
@ -133,6 +147,45 @@ async def async_restore_block_attribute_entities(
async_add_entities(entities) async_add_entities(entities)
async def async_setup_entry_rpc(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
sensors: dict[str, RpcAttributeDescription],
sensor_class: Callable,
) -> None:
"""Set up entities for REST sensors."""
wrapper: RpcDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][
config_entry.entry_id
][RPC]
entities = []
for sensor_id in sensors:
description = sensors[sensor_id]
key_instances = get_rpc_key_instances(wrapper.device.status, description.key)
for key in key_instances:
# Filter and remove entities that according to settings should not create an entity
if description.removal_condition and description.removal_condition(
wrapper.device.config, key
):
domain = sensor_class.__module__.split(".")[-1]
unique_id = f"{wrapper.mac}-{key}-{sensor_id}"
await async_remove_shelly_entity(hass, domain, unique_id)
else:
entities.append((key, sensor_id, description))
if not entities:
return
async_add_entities(
[
sensor_class(wrapper, key, sensor_id, description)
for key, sensor_id, description in entities
]
)
async def async_setup_entry_rest( async def async_setup_entry_rest(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntry,
@ -175,10 +228,27 @@ class BlockAttributeDescription:
device_class: str | None = None device_class: str | None = None
state_class: str | None = None state_class: str | None = None
default_enabled: bool = True default_enabled: bool = True
available: Callable[[aioshelly.Block], bool] | None = None available: Callable[[Block], bool] | None = None
# Callable (settings, block), return true if entity should be removed # Callable (settings, block), return true if entity should be removed
removal_condition: Callable[[dict, aioshelly.Block], bool] | None = None removal_condition: Callable[[dict, Block], bool] | None = None
extra_state_attributes: Callable[[aioshelly.Block], dict | None] | None = None extra_state_attributes: Callable[[Block], dict | None] | None = None
@dataclass
class RpcAttributeDescription:
"""Class to describe a RPC sensor."""
key: str
name: str
icon: str | None = None
unit: str | None = None
value: Callable[[dict, Any], Any] | None = None
device_class: str | None = None
state_class: str | None = None
default_enabled: bool = True
available: Callable[[dict], bool] | None = None
removal_condition: Callable[[dict, str], bool] | None = None
extra_state_attributes: Callable[[dict], dict | None] | None = None
@dataclass @dataclass
@ -196,13 +266,13 @@ class RestAttributeDescription:
class ShellyBlockEntity(entity.Entity): class ShellyBlockEntity(entity.Entity):
"""Helper class to represent a block.""" """Helper class to represent a block entity."""
def __init__(self, wrapper: ShellyDeviceWrapper, block: aioshelly.Block) -> None: def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None:
"""Initialize Shelly entity.""" """Initialize Shelly entity."""
self.wrapper = wrapper self.wrapper = wrapper
self.block = block self.block = block
self._name = get_entity_name(wrapper.device, block) self._name = get_block_entity_name(wrapper.device, block)
@property @property
def name(self) -> str: def name(self) -> str:
@ -261,13 +331,68 @@ class ShellyBlockEntity(entity.Entity):
return None 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): class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity):
"""Helper class to represent a block attribute.""" """Helper class to represent a block attribute."""
def __init__( def __init__(
self, self,
wrapper: ShellyDeviceWrapper, wrapper: BlockDeviceWrapper,
block: aioshelly.Block, block: Block,
attribute: str, attribute: str,
description: BlockAttributeDescription, description: BlockAttributeDescription,
) -> None: ) -> None:
@ -283,7 +408,7 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity):
self._unit: None | str | Callable[[dict], str] = unit self._unit: None | str | Callable[[dict], str] = unit
self._unique_id: str = f"{super().unique_id}-{self.attribute}" 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 @property
def unique_id(self) -> str: def unique_id(self) -> str:
@ -344,7 +469,7 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity):
def __init__( def __init__(
self, self,
wrapper: ShellyDeviceWrapper, wrapper: BlockDeviceWrapper,
attribute: str, attribute: str,
description: RestAttributeDescription, description: RestAttributeDescription,
) -> None: ) -> None:
@ -353,7 +478,7 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity):
self.wrapper = wrapper self.wrapper = wrapper
self.attribute = attribute self.attribute = attribute
self.description = description 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 self._last_value = None
@property @property
@ -411,14 +536,66 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity):
return self.description.extra_state_attributes(self.wrapper.device.status) return self.description.extra_state_attributes(self.wrapper.device.status)
class ShellyRpcAttributeEntity(ShellyRpcEntity, entity.Entity):
"""Helper class to represent a rpc attribute."""
def __init__(
self,
wrapper: RpcDeviceWrapper,
key: str,
attribute: str,
description: RpcAttributeDescription,
) -> None:
"""Initialize sensor."""
super().__init__(wrapper, key)
self.attribute = attribute
self.description = description
self._attr_unique_id = f"{super().unique_id}-{attribute}"
self._attr_name = get_rpc_entity_name(wrapper.device, key, description.name)
self._attr_entity_registry_enabled_default = description.default_enabled
self._attr_device_class = description.device_class
self._attr_icon = description.icon
self._last_value = None
@property
def attribute_value(self) -> StateType:
"""Value of sensor."""
if callable(self.description.value):
self._last_value = self.description.value(
self.wrapper.device.status[self.key], self._last_value
)
return self._last_value
@property
def available(self) -> bool:
"""Available."""
available = super().available
if not available or not self.description.available:
return available
return self.description.available(self.wrapper.device.status[self.key])
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes."""
if self.description.extra_state_attributes is None:
return None
return self.description.extra_state_attributes(
self.wrapper.device.status[self.key]
)
class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEntity): class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEntity):
"""Represent a shelly sleeping block attribute entity.""" """Represent a shelly sleeping block attribute entity."""
# pylint: disable=super-init-not-called # pylint: disable=super-init-not-called
def __init__( def __init__(
self, self,
wrapper: ShellyDeviceWrapper, wrapper: BlockDeviceWrapper,
block: aioshelly.Block, block: Block | None,
attribute: str, attribute: str,
description: BlockAttributeDescription, description: BlockAttributeDescription,
entry: entity_registry.RegistryEntry | None = None, entry: entity_registry.RegistryEntry | None = None,
@ -429,7 +606,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti
self.last_state: StateType = None self.last_state: StateType = None
self.wrapper = wrapper self.wrapper = wrapper
self.attribute = attribute self.attribute = attribute
self.block = block self.block: Block | None = block # type: ignore[assignment]
self.description = description self.description = description
self._unit = self.description.unit self._unit = self.description.unit
@ -438,7 +615,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti
self._unit = self._unit(block.info(attribute)) self._unit = self._unit(block.info(attribute))
self._unique_id = f"{self.wrapper.mac}-{block.description}-{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 self.wrapper.device, block, self.description.name
) )
elif entry is not None: elif entry is not None:
@ -468,6 +645,8 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti
_, entity_block, entity_sensor = self.unique_id.split("-") _, entity_block, entity_sensor = self.unique_id.split("-")
assert self.wrapper.device.blocks
for block in self.wrapper.device.blocks: for block in self.wrapper.device.blocks:
if block.description != entity_block: if block.description != entity_block:
continue continue

View File

@ -5,7 +5,7 @@ import asyncio
import logging import logging
from typing import Any, Final, cast from typing import Any, Final, cast
from aioshelly import Block from aioshelly.block_device import Block
import async_timeout import async_timeout
from homeassistant.components.light import ( from homeassistant.components.light import (
@ -33,10 +33,10 @@ from homeassistant.util.color import (
color_temperature_mired_to_kelvin, color_temperature_mired_to_kelvin,
) )
from . import ShellyDeviceWrapper from . import BlockDeviceWrapper, RpcDeviceWrapper
from .const import ( from .const import (
AIOSHELLY_DEVICE_TIMEOUT_SEC, AIOSHELLY_DEVICE_TIMEOUT_SEC,
COAP, BLOCK,
DATA_CONFIG_ENTRY, DATA_CONFIG_ENTRY,
DOMAIN, DOMAIN,
FIRMWARE_PATTERN, FIRMWARE_PATTERN,
@ -46,11 +46,18 @@ from .const import (
LIGHT_TRANSITION_MIN_FIRMWARE_DATE, LIGHT_TRANSITION_MIN_FIRMWARE_DATE,
MAX_TRANSITION_TIME, MAX_TRANSITION_TIME,
MODELS_SUPPORTING_LIGHT_TRANSITION, MODELS_SUPPORTING_LIGHT_TRANSITION,
RPC,
SHBLB_1_RGB_EFFECTS, SHBLB_1_RGB_EFFECTS,
STANDARD_RGB_EFFECTS, STANDARD_RGB_EFFECTS,
) )
from .entity import ShellyBlockEntity from .entity import ShellyBlockEntity, ShellyRpcEntity
from .utils import async_remove_shelly_entity from .utils import (
async_remove_shelly_entity,
get_device_entry_gen,
get_rpc_key_ids,
is_block_channel_type_light,
is_rpc_channel_type_light,
)
_LOGGER: Final = logging.getLogger(__name__) _LOGGER: Final = logging.getLogger(__name__)
@ -61,33 +68,70 @@ 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."""
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 = [] blocks = []
assert wrapper.device.blocks
for block in wrapper.device.blocks: for block in wrapper.device.blocks:
if block.type == "light": if block.type == "light":
blocks.append(block) blocks.append(block)
elif block.type == "relay": elif block.type == "relay":
appliance_type = wrapper.device.settings["relays"][int(block.channel)].get( if not is_block_channel_type_light(
"appliance_type" wrapper.device.settings, int(block.channel)
) ):
if appliance_type and appliance_type.lower() == "light": continue
blocks.append(block)
unique_id = ( blocks.append(block)
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) await async_remove_shelly_entity(hass, "switch", unique_id)
if not blocks: if not blocks:
return 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): async def async_setup_rpc_entry(
"""Switch that controls a relay block on Shelly devices.""" 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]
switch_key_ids = get_rpc_key_ids(wrapper.device.status, "switch")
def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: switch_ids = []
for id_ in switch_key_ids:
if not is_rpc_channel_type_light(wrapper.device.config, id_):
continue
switch_ids.append(id_)
unique_id = f"{wrapper.mac}-switch:{id_}"
await async_remove_shelly_entity(hass, "switch", unique_id)
if not switch_ids:
return
async_add_entities(RpcShellyLight(wrapper, id_) for id_ in switch_ids)
class BlockShellyLight(ShellyBlockEntity, LightEntity):
"""Entity that controls a light on block based Shelly devices."""
def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None:
"""Initialize light.""" """Initialize light."""
super().__init__(wrapper, block) super().__init__(wrapper, block)
self.control_result: dict[str, Any] | None = None self.control_result: dict[str, Any] | None = None
@ -117,7 +161,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity):
self._supported_features |= SUPPORT_EFFECT self._supported_features |= SUPPORT_EFFECT
if wrapper.model in MODELS_SUPPORTING_LIGHT_TRANSITION: if wrapper.model in MODELS_SUPPORTING_LIGHT_TRANSITION:
match = FIRMWARE_PATTERN.search(wrapper.device.settings.get("fw")) match = FIRMWARE_PATTERN.search(wrapper.device.settings.get("fw", ""))
if ( if (
match is not None match is not None
and int(match[0]) >= LIGHT_TRANSITION_MIN_FIRMWARE_DATE and int(match[0]) >= LIGHT_TRANSITION_MIN_FIRMWARE_DATE
@ -369,3 +413,25 @@ class ShellyLight(ShellyBlockEntity, LightEntity):
self.control_result = None self.control_result = None
self.mode_result = None self.mode_result = None
super()._update_callback() super()._update_callback()
class RpcShellyLight(ShellyRpcEntity, LightEntity):
"""Entity that controls a light on RPC based Shelly devices."""
def __init__(self, wrapper: RpcDeviceWrapper, id_: int) -> None:
"""Initialize light."""
super().__init__(wrapper, f"switch:{id_}")
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,15 +7,17 @@ from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.typing import EventType from homeassistant.helpers.typing import EventType
from . import get_device_wrapper from . import get_block_device_wrapper, get_rpc_device_wrapper
from .const import ( from .const import (
ATTR_CHANNEL, ATTR_CHANNEL,
ATTR_CLICK_TYPE, ATTR_CLICK_TYPE,
ATTR_DEVICE, ATTR_DEVICE,
BLOCK_INPUTS_EVENTS_TYPES,
DOMAIN, DOMAIN,
EVENT_SHELLY_CLICK, EVENT_SHELLY_CLICK,
RPC_INPUTS_EVENTS_TYPES,
) )
from .utils import get_device_name from .utils import get_block_device_name, get_rpc_entity_name
@callback @callback
@ -27,19 +29,27 @@ def async_describe_events(
@callback @callback
def async_describe_shelly_click_event(event: EventType) -> dict[str, str]: def async_describe_shelly_click_event(event: EventType) -> dict[str, str]:
"""Describe shelly.click logbook event.""" """Describe shelly.click logbook event (block device)."""
wrapper = get_device_wrapper(hass, event.data[ATTR_DEVICE_ID]) device_id = event.data[ATTR_DEVICE_ID]
if wrapper and wrapper.device.initialized:
device_name = get_device_name(wrapper.device)
else:
device_name = event.data[ATTR_DEVICE]
channel = event.data[ATTR_CHANNEL]
click_type = event.data[ATTR_CLICK_TYPE] click_type = event.data[ATTR_CLICK_TYPE]
channel = event.data[ATTR_CHANNEL]
input_name = f"{event.data[ATTR_DEVICE]} channel {channel}"
if click_type in RPC_INPUTS_EVENTS_TYPES:
rpc_wrapper = get_rpc_device_wrapper(hass, device_id)
if rpc_wrapper and rpc_wrapper.device.initialized:
key = f"input:{channel-1}"
input_name = get_rpc_entity_name(rpc_wrapper.device, key)
elif click_type in BLOCK_INPUTS_EVENTS_TYPES:
block_wrapper = get_block_device_wrapper(hass, device_id)
if block_wrapper and block_wrapper.device.initialized:
device_name = get_block_device_name(block_wrapper.device)
input_name = f"{device_name} channel {channel}"
return { return {
"name": "Shelly", "name": "Shelly",
"message": f"'{click_type}' click event for {device_name} channel {channel} was fired.", "message": f"'{click_type}' click event for {input_name} Input was fired.",
} }
async_describe_event(DOMAIN, EVENT_SHELLY_CLICK, async_describe_shelly_click_event) async_describe_event(DOMAIN, EVENT_SHELLY_CLICK, async_describe_shelly_click_event)

View File

@ -3,7 +3,7 @@
"name": "Shelly", "name": "Shelly",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/shelly", "documentation": "https://www.home-assistant.io/integrations/shelly",
"requirements": ["aioshelly==0.6.4"], "requirements": ["aioshelly==1.0.1"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_http._tcp.local.", "type": "_http._tcp.local.",

View File

@ -16,6 +16,7 @@ from homeassistant.const import (
PERCENTAGE, PERCENTAGE,
POWER_WATT, POWER_WATT,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT, SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
TEMP_CELSIUS,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -25,13 +26,16 @@ from .const import SHAIR_MAX_WORK_HOURS
from .entity import ( from .entity import (
BlockAttributeDescription, BlockAttributeDescription,
RestAttributeDescription, RestAttributeDescription,
RpcAttributeDescription,
ShellyBlockAttributeEntity, ShellyBlockAttributeEntity,
ShellyRestAttributeEntity, ShellyRestAttributeEntity,
ShellyRpcAttributeEntity,
ShellySleepingBlockAttributeEntity, ShellySleepingBlockAttributeEntity,
async_setup_entry_attribute_entities, async_setup_entry_attribute_entities,
async_setup_entry_rest, async_setup_entry_rest,
async_setup_entry_rpc,
) )
from .utils import get_device_uptime, temperature_unit from .utils import get_device_entry_gen, get_device_uptime, temperature_unit
SENSORS: Final = { SENSORS: Final = {
("device", "battery"): BlockAttributeDescription( ("device", "battery"): BlockAttributeDescription(
@ -40,7 +44,7 @@ SENSORS: Final = {
device_class=sensor.DEVICE_CLASS_BATTERY, device_class=sensor.DEVICE_CLASS_BATTERY,
state_class=sensor.STATE_CLASS_MEASUREMENT, state_class=sensor.STATE_CLASS_MEASUREMENT,
removal_condition=lambda settings, _: settings.get("external_power") == 1, removal_condition=lambda settings, _: settings.get("external_power") == 1,
available=lambda block: cast(bool, block.battery != -1), available=lambda block: cast(int, block.battery) != -1,
), ),
("device", "deviceTemp"): BlockAttributeDescription( ("device", "deviceTemp"): BlockAttributeDescription(
name="Device Temperature", name="Device Temperature",
@ -162,7 +166,7 @@ SENSORS: Final = {
value=lambda value: round(value, 1), value=lambda value: round(value, 1),
device_class=sensor.DEVICE_CLASS_TEMPERATURE, device_class=sensor.DEVICE_CLASS_TEMPERATURE,
state_class=sensor.STATE_CLASS_MEASUREMENT, state_class=sensor.STATE_CLASS_MEASUREMENT,
available=lambda block: cast(bool, block.extTemp != 999), available=lambda block: cast(int, block.extTemp) != 999,
), ),
("sensor", "humidity"): BlockAttributeDescription( ("sensor", "humidity"): BlockAttributeDescription(
name="Humidity", name="Humidity",
@ -170,14 +174,14 @@ SENSORS: Final = {
value=lambda value: round(value, 1), value=lambda value: round(value, 1),
device_class=sensor.DEVICE_CLASS_HUMIDITY, device_class=sensor.DEVICE_CLASS_HUMIDITY,
state_class=sensor.STATE_CLASS_MEASUREMENT, state_class=sensor.STATE_CLASS_MEASUREMENT,
available=lambda block: cast(bool, block.extTemp != 999), available=lambda block: cast(int, block.extTemp) != 999,
), ),
("sensor", "luminosity"): BlockAttributeDescription( ("sensor", "luminosity"): BlockAttributeDescription(
name="Luminosity", name="Luminosity",
unit=LIGHT_LUX, unit=LIGHT_LUX,
device_class=sensor.DEVICE_CLASS_ILLUMINANCE, device_class=sensor.DEVICE_CLASS_ILLUMINANCE,
state_class=sensor.STATE_CLASS_MEASUREMENT, state_class=sensor.STATE_CLASS_MEASUREMENT,
available=lambda block: cast(bool, block.luminosity != -1), available=lambda block: cast(int, block.luminosity) != -1,
), ),
("sensor", "tilt"): BlockAttributeDescription( ("sensor", "tilt"): BlockAttributeDescription(
name="Tilt", name="Tilt",
@ -191,7 +195,7 @@ SENSORS: Final = {
icon="mdi:progress-wrench", icon="mdi:progress-wrench",
value=lambda value: round(100 - (value / 3600 / SHAIR_MAX_WORK_HOURS), 1), value=lambda value: round(100 - (value / 3600 / SHAIR_MAX_WORK_HOURS), 1),
extra_state_attributes=lambda block: { extra_state_attributes=lambda block: {
"Operational hours": round(block.totalWorkTime / 3600, 1) "Operational hours": round(cast(int, block.totalWorkTime) / 3600, 1)
}, },
), ),
("adc", "adc"): BlockAttributeDescription( ("adc", "adc"): BlockAttributeDescription(
@ -220,7 +224,60 @@ REST_SENSORS: Final = {
), ),
"uptime": RestAttributeDescription( "uptime": RestAttributeDescription(
name="Uptime", name="Uptime",
value=get_device_uptime, value=lambda status, last: get_device_uptime(status["uptime"], last),
device_class=sensor.DEVICE_CLASS_TIMESTAMP,
default_enabled=False,
),
}
RPC_SENSORS: Final = {
"power": RpcAttributeDescription(
key="switch",
name="Power",
unit=POWER_WATT,
value=lambda status, _: round(float(status["apower"]), 1),
device_class=sensor.DEVICE_CLASS_POWER,
state_class=sensor.STATE_CLASS_MEASUREMENT,
),
"voltage": RpcAttributeDescription(
key="switch",
name="Voltage",
unit=ELECTRIC_POTENTIAL_VOLT,
value=lambda status, _: round(float(status["voltage"]), 1),
device_class=sensor.DEVICE_CLASS_VOLTAGE,
state_class=sensor.STATE_CLASS_MEASUREMENT,
),
"energy": RpcAttributeDescription(
key="switch",
name="Energy",
unit=ENERGY_KILO_WATT_HOUR,
value=lambda status, _: round(status["aenergy"]["total"] / 1000, 2),
device_class=sensor.DEVICE_CLASS_ENERGY,
state_class=sensor.STATE_CLASS_TOTAL_INCREASING,
),
"temperature": RpcAttributeDescription(
key="switch",
name="Temperature",
unit=TEMP_CELSIUS,
value=lambda status, _: round(status["temperature"]["tC"], 1),
device_class=sensor.DEVICE_CLASS_TEMPERATURE,
state_class=sensor.STATE_CLASS_MEASUREMENT,
default_enabled=False,
),
"rssi": RpcAttributeDescription(
key="wifi",
name="RSSI",
unit=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
value=lambda status, _: status["rssi"],
device_class=sensor.DEVICE_CLASS_SIGNAL_STRENGTH,
state_class=sensor.STATE_CLASS_MEASUREMENT,
default_enabled=False,
),
"uptime": RpcAttributeDescription(
key="sys",
name="Uptime",
value=lambda status, last: get_device_uptime(status["uptime"], last),
device_class=sensor.DEVICE_CLASS_TIMESTAMP, device_class=sensor.DEVICE_CLASS_TIMESTAMP,
default_enabled=False, default_enabled=False,
), ),
@ -233,21 +290,26 @@ 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:
return await async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_SENSORS, RpcSensor
)
if config_entry.data["sleep_period"]: if config_entry.data["sleep_period"]:
await async_setup_entry_attribute_entities( await async_setup_entry_attribute_entities(
hass, config_entry, async_add_entities, SENSORS, ShellySleepingSensor hass, config_entry, async_add_entities, SENSORS, BlockSleepingSensor
) )
else: else:
await async_setup_entry_attribute_entities( await async_setup_entry_attribute_entities(
hass, config_entry, async_add_entities, SENSORS, ShellySensor hass, config_entry, async_add_entities, SENSORS, BlockSensor
) )
await async_setup_entry_rest( await async_setup_entry_rest(
hass, config_entry, async_add_entities, REST_SENSORS, ShellyRestSensor hass, config_entry, async_add_entities, REST_SENSORS, RestSensor
) )
class ShellySensor(ShellyBlockAttributeEntity, SensorEntity): class BlockSensor(ShellyBlockAttributeEntity, SensorEntity):
"""Represent a shelly sensor.""" """Represent a block sensor."""
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType:
@ -265,8 +327,8 @@ class ShellySensor(ShellyBlockAttributeEntity, SensorEntity):
return cast(str, self._unit) return cast(str, self._unit)
class ShellyRestSensor(ShellyRestAttributeEntity, SensorEntity): class RestSensor(ShellyRestAttributeEntity, SensorEntity):
"""Represent a shelly REST sensor.""" """Represent a REST sensor."""
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType:
@ -284,8 +346,27 @@ class ShellyRestSensor(ShellyRestAttributeEntity, SensorEntity):
return self.description.unit return self.description.unit
class ShellySleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): class RpcSensor(ShellyRpcAttributeEntity, SensorEntity):
"""Represent a shelly sleeping sensor.""" """Represent a RPC sensor."""
@property
def native_value(self) -> StateType:
"""Return value of sensor."""
return self.attribute_value
@property
def state_class(self) -> str | None:
"""State class of sensor."""
return self.description.state_class
@property
def native_unit_of_measurement(self) -> str | None:
"""Return unit of sensor."""
return self.description.unit
class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity):
"""Represent a block sleeping sensor."""
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType:

View File

@ -33,7 +33,8 @@
"button": "Button", "button": "Button",
"button1": "First button", "button1": "First button",
"button2": "Second button", "button2": "Second button",
"button3": "Third button" "button3": "Third button",
"button4": "Fourth button"
}, },
"trigger_type": { "trigger_type": {
"single": "{subtype} single clicked", "single": "{subtype} single clicked",
@ -41,7 +42,12 @@
"triple": "{subtype} triple clicked", "triple": "{subtype} triple clicked",
"long": " {subtype} long clicked", "long": " {subtype} long clicked",
"single_long": "{subtype} single clicked and then long clicked", "single_long": "{subtype} single clicked and then long clicked",
"long_single": "{subtype} long clicked and then single clicked" "long_single": "{subtype} long clicked and then single clicked",
"btn_down": "{subtype} button down",
"btn_up": "{subtype} button up",
"single_push": "{subtype} single push",
"double_push": "{subtype} double push",
"long_push": " {subtype} long push"
} }
} }
} }

View File

@ -3,17 +3,23 @@ from __future__ import annotations
from typing import Any, cast from typing import Any, cast
from aioshelly import Block from aioshelly.block_device import Block
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ShellyDeviceWrapper from . import BlockDeviceWrapper, RpcDeviceWrapper
from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN, RPC
from .entity import ShellyBlockEntity from .entity import ShellyBlockEntity, ShellyRpcEntity
from .utils import async_remove_shelly_entity from .utils import (
async_remove_shelly_entity,
get_device_entry_gen,
get_rpc_key_ids,
is_block_channel_type_light,
is_rpc_channel_type_light,
)
async def async_setup_entry( async def async_setup_entry(
@ -22,7 +28,19 @@ 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."""
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 # In roller mode the relay blocks exist but do not contain required info
if ( if (
@ -32,32 +50,51 @@ async def async_setup_entry(
return return
relay_blocks = [] relay_blocks = []
assert wrapper.device.blocks
for block in wrapper.device.blocks: for block in wrapper.device.blocks:
if block.type == "relay": if block.type != "relay" or is_block_channel_type_light(
appliance_type = wrapper.device.settings["relays"][int(block.channel)].get( wrapper.device.settings, int(block.channel)
"appliance_type" ):
) continue
if not appliance_type or appliance_type.lower() != "light":
relay_blocks.append(block) relay_blocks.append(block)
unique_id = ( unique_id = f"{wrapper.mac}-{block.type}_{block.channel}"
f'{wrapper.device.shelly["mac"]}-{block.type}_{block.channel}' await async_remove_shelly_entity(hass, "light", unique_id)
)
await async_remove_shelly_entity(
hass,
"light",
unique_id,
)
if not relay_blocks: if not relay_blocks:
return 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): async def async_setup_rpc_entry(
"""Switch that controls a relay block on Shelly devices.""" 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]
switch_key_ids = get_rpc_key_ids(wrapper.device.status, "switch")
def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: switch_ids = []
for id_ in switch_key_ids:
if is_rpc_channel_type_light(wrapper.device.config, id_):
continue
switch_ids.append(id_)
unique_id = f"{wrapper.mac}-switch:{id_}"
await async_remove_shelly_entity(hass, "light", unique_id)
if not switch_ids:
return
async_add_entities(RpcRelaySwitch(wrapper, id_) for id_ in switch_ids)
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.""" """Initialize relay switch."""
super().__init__(wrapper, block) super().__init__(wrapper, block)
self.control_result: dict[str, Any] | None = None self.control_result: dict[str, Any] | None = None
@ -85,3 +122,25 @@ class RelaySwitch(ShellyBlockEntity, SwitchEntity):
"""When device updates, clear control result that overrides state.""" """When device updates, clear control result that overrides state."""
self.control_result = None self.control_result = None
super()._update_callback() super()._update_callback()
class RpcRelaySwitch(ShellyRpcEntity, SwitchEntity):
"""Entity that controls a relay on RPC based Shelly devices."""
def __init__(self, wrapper: RpcDeviceWrapper, id_: int) -> None:
"""Initialize relay switch."""
super().__init__(wrapper, f"switch:{id_}")
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

@ -33,7 +33,8 @@
"button": "Button", "button": "Button",
"button1": "First button", "button1": "First button",
"button2": "Second button", "button2": "Second button",
"button3": "Third button" "button3": "Third button",
"button4": "Fourth button"
}, },
"trigger_type": { "trigger_type": {
"double": "{subtype} double clicked", "double": "{subtype} double clicked",
@ -41,7 +42,12 @@
"long_single": "{subtype} long clicked and then single clicked", "long_single": "{subtype} long clicked and then single clicked",
"single": "{subtype} single clicked", "single": "{subtype} single clicked",
"single_long": "{subtype} single clicked and then long clicked", "single_long": "{subtype} single clicked and then long clicked",
"triple": "{subtype} triple clicked" "triple": "{subtype} triple clicked",
"btn_down": "{subtype} button down",
"btn_up": "{subtype} button up",
"single_push": "{subtype} single push",
"double_push": "{subtype} double push",
"long_push": " {subtype} long push"
} }
} }
} }

View File

@ -5,8 +5,11 @@ from datetime import datetime, timedelta
import logging import logging
from typing import Any, Final, cast from typing import Any, Final, cast
import aioshelly 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.const import EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, TEMP_FAHRENHEIT
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import singleton from homeassistant.helpers import singleton
@ -18,6 +21,8 @@ from .const import (
CONF_COAP_PORT, CONF_COAP_PORT,
DEFAULT_COAP_PORT, DEFAULT_COAP_PORT,
DOMAIN, DOMAIN,
MAX_RPC_KEY_INSTANCES,
RPC_INPUTS_EVENTS_TYPES,
SHBTN_INPUTS_EVENTS_TYPES, SHBTN_INPUTS_EVENTS_TYPES,
SHBTN_MODELS, SHBTN_MODELS,
SHIX3_1_INPUTS_EVENTS_TYPES, SHIX3_1_INPUTS_EVENTS_TYPES,
@ -40,18 +45,27 @@ async def async_remove_shelly_entity(
def temperature_unit(block_info: dict[str, Any]) -> str: def temperature_unit(block_info: dict[str, Any]) -> str:
"""Detect temperature unit.""" """Detect temperature unit."""
if block_info[aioshelly.BLOCK_VALUE_UNIT] == "F": if block_info[BLOCK_VALUE_UNIT] == "F":
return TEMP_FAHRENHEIT return TEMP_FAHRENHEIT
return TEMP_CELSIUS return TEMP_CELSIUS
def get_device_name(device: aioshelly.Device) -> str: def get_block_device_name(device: BlockDevice) -> str:
"""Naming for device.""" """Naming for device."""
return cast(str, device.settings["name"] or device.settings["device"]["hostname"]) return cast(str, device.settings["name"] or device.settings["device"]["hostname"])
def get_number_of_channels(device: aioshelly.Device, block: aioshelly.Block) -> int: def get_rpc_device_name(device: RpcDevice) -> str:
"""Naming for device."""
# Gen2 does not support setting device name
# AP SSID name is used as a nicely formatted device name
return cast(str, device.config["wifi"]["ap"]["ssid"] or device.hostname)
def get_number_of_channels(device: BlockDevice, block: Block) -> int:
"""Get number of channels for block type.""" """Get number of channels for block type."""
assert isinstance(device.shelly, dict)
channels = None channels = None
if block.type == "input": if block.type == "input":
@ -70,13 +84,13 @@ def get_number_of_channels(device: aioshelly.Device, block: aioshelly.Block) ->
return channels or 1 return channels or 1
def get_entity_name( def get_block_entity_name(
device: aioshelly.Device, device: BlockDevice,
block: aioshelly.Block, block: Block | None,
description: str | None = None, description: str | None = None,
) -> str: ) -> str:
"""Naming for switch and sensors.""" """Naming for block based switch and sensors."""
channel_name = get_device_channel_name(device, block) channel_name = get_block_channel_name(device, block)
if description: if description:
return f"{channel_name} {description}" return f"{channel_name} {description}"
@ -84,12 +98,9 @@ def get_entity_name(
return channel_name return channel_name
def get_device_channel_name( def get_block_channel_name(device: BlockDevice, block: Block | None) -> str:
device: aioshelly.Device,
block: aioshelly.Block,
) -> str:
"""Get name based on device and channel name.""" """Get name based on device and channel name."""
entity_name = get_device_name(device) entity_name = get_block_device_name(device)
if ( if (
not block not block
@ -98,8 +109,10 @@ def get_device_channel_name(
): ):
return entity_name return entity_name
assert block.channel
channel_name: str | None = None channel_name: str | None = None
mode = block.type + "s" mode = cast(str, block.type) + "s"
if mode in device.settings: if mode in device.settings:
channel_name = device.settings[mode][int(block.channel)].get("name") channel_name = device.settings[mode][int(block.channel)].get("name")
@ -114,8 +127,8 @@ def get_device_channel_name(
return f"{entity_name} channel {chr(int(block.channel)+base)}" return f"{entity_name} channel {chr(int(block.channel)+base)}"
def is_momentary_input(settings: dict[str, Any], block: aioshelly.Block) -> bool: def is_block_momentary_input(settings: dict[str, Any], block: Block) -> bool:
"""Return true if input button settings is set to a momentary type.""" """Return true if block input button settings is set to a momentary type."""
# Shelly Button type is fixed to momentary and no btn_type # Shelly Button type is fixed to momentary and no btn_type
if settings["device"]["type"] in SHBTN_MODELS: if settings["device"]["type"] in SHBTN_MODELS:
return True return True
@ -136,9 +149,9 @@ def is_momentary_input(settings: dict[str, Any], block: aioshelly.Block) -> bool
return button_type in ["momentary", "momentary_on_release"] return button_type in ["momentary", "momentary_on_release"]
def get_device_uptime(status: dict[str, Any], last_uptime: str | None) -> str: def get_device_uptime(uptime: float, last_uptime: str | None) -> str:
"""Return device uptime string, tolerate up to 5 seconds deviation.""" """Return device uptime string, tolerate up to 5 seconds deviation."""
delta_uptime = utcnow() - timedelta(seconds=status["uptime"]) delta_uptime = utcnow() - timedelta(seconds=uptime)
if ( if (
not last_uptime not last_uptime
@ -150,14 +163,14 @@ def get_device_uptime(status: dict[str, Any], last_uptime: str | None) -> str:
return last_uptime return last_uptime
def get_input_triggers( def get_block_input_triggers(
device: aioshelly.Device, block: aioshelly.Block device: BlockDevice, block: Block
) -> list[tuple[str, str]]: ) -> list[tuple[str, str]]:
"""Return list of input triggers for block.""" """Return list of input triggers for block."""
if "inputEvent" not in block.sensor_ids or "inputEventCnt" not in block.sensor_ids: if "inputEvent" not in block.sensor_ids or "inputEventCnt" not in block.sensor_ids:
return [] return []
if not is_momentary_input(device.settings, block): if not is_block_momentary_input(device.settings, block):
return [] return []
triggers = [] triggers = []
@ -165,6 +178,7 @@ def get_input_triggers(
if block.type == "device" or get_number_of_channels(device, block) == 1: if block.type == "device" or get_number_of_channels(device, block) == 1:
subtype = "button" subtype = "button"
else: else:
assert block.channel
subtype = f"button{int(block.channel)+1}" subtype = f"button{int(block.channel)+1}"
if device.settings["device"]["type"] in SHBTN_MODELS: if device.settings["device"]["type"] in SHBTN_MODELS:
@ -180,10 +194,20 @@ def get_input_triggers(
return triggers return triggers
def get_shbtn_input_triggers() -> list[tuple[str, str]]:
"""Return list of input triggers for SHBTN models."""
triggers = []
for trigger_type in SHBTN_INPUTS_EVENTS_TYPES:
triggers.append((trigger_type, "button"))
return triggers
@singleton.singleton("shelly_coap") @singleton.singleton("shelly_coap")
async def get_coap_context(hass: HomeAssistant) -> aioshelly.COAP: async def get_coap_context(hass: HomeAssistant) -> COAP:
"""Get CoAP context to be used in all Shelly devices.""" """Get CoAP context to be used in all Shelly devices."""
context = aioshelly.COAP() context = COAP()
if DOMAIN in hass.data: if DOMAIN in hass.data:
port = hass.data[DOMAIN].get(CONF_COAP_PORT, DEFAULT_COAP_PORT) port = hass.data[DOMAIN].get(CONF_COAP_PORT, DEFAULT_COAP_PORT)
else: else:
@ -200,7 +224,7 @@ async def get_coap_context(hass: HomeAssistant) -> aioshelly.COAP:
return context return context
def get_device_sleep_period(settings: dict[str, Any]) -> int: def get_block_device_sleep_period(settings: dict[str, Any]) -> int:
"""Return the device sleep period in seconds or 0 for non sleeping devices.""" """Return the device sleep period in seconds or 0 for non sleeping devices."""
sleep_period = 0 sleep_period = 0
@ -210,3 +234,114 @@ def get_device_sleep_period(settings: dict[str, Any]) -> int:
sleep_period *= 60 # hours to minutes sleep_period *= 60 # hours to minutes
return sleep_period * 60 # minutes to seconds return sleep_period * 60 # minutes to seconds
def get_info_auth(info: dict[str, Any]) -> bool:
"""Return true if device has authorization enabled."""
return cast(bool, info.get("auth") or info.get("auth_en"))
def get_info_gen(info: dict[str, Any]) -> int:
"""Return the device generation from shelly info."""
return int(info.get("gen", 1))
def get_model_name(info: dict[str, Any]) -> str:
"""Return the device model name."""
if get_info_gen(info) == 2:
return cast(str, MODEL_NAMES.get(info["model"], info["model"]))
return cast(str, MODEL_NAMES.get(info["type"], info["type"]))
def get_rpc_channel_name(device: RpcDevice, key: str) -> str:
"""Get name based on device and channel name."""
key = key.replace("input", "switch")
device_name = get_rpc_device_name(device)
entity_name: str | None = device.config[key].get("name", device_name)
if entity_name is None:
return f"{device_name} {key.replace(':', '_')}"
return entity_name
def get_rpc_entity_name(
device: RpcDevice, key: str, description: str | None = None
) -> str:
"""Naming for RPC based switch and sensors."""
channel_name = get_rpc_channel_name(device, key)
if description:
return f"{channel_name} {description}"
return channel_name
def get_device_entry_gen(entry: ConfigEntry) -> int:
"""Return the device generation from config entry."""
return entry.data.get("gen", 1)
def get_rpc_key_instances(keys_dict: dict[str, Any], key: str) -> list[str]:
"""Return list of key instances for RPC device from a dict."""
if key in keys_dict:
return [key]
keys_list: list[str] = []
for i in range(MAX_RPC_KEY_INSTANCES):
key_inst = f"{key}:{i}"
if key_inst not in keys_dict:
return keys_list
keys_list.append(key_inst)
return keys_list
def get_rpc_key_ids(keys_dict: dict[str, Any], key: str) -> list[int]:
"""Return list of key ids for RPC device from a dict."""
key_ids: list[int] = []
for i in range(MAX_RPC_KEY_INSTANCES):
key_inst = f"{key}:{i}"
if key_inst not in keys_dict:
return key_ids
key_ids.append(i)
return key_ids
def is_rpc_momentary_input(config: dict[str, Any], key: str) -> bool:
"""Return true if rpc input button settings is set to a momentary type."""
return cast(bool, config[key]["type"] == "button")
def is_block_channel_type_light(settings: dict[str, Any], channel: int) -> bool:
"""Return true if block channel appliance type is set to light."""
app_type = settings["relays"][channel].get("appliance_type")
return app_type is not None and app_type.lower().startswith("light")
def is_rpc_channel_type_light(config: dict[str, Any], channel: int) -> bool:
"""Return true if rpc channel consumption type is set to light."""
con_types = config["sys"]["ui_data"].get("consumption_types")
return con_types is not None and con_types[channel].lower().startswith("light")
def get_rpc_input_triggers(device: RpcDevice) -> list[tuple[str, str]]:
"""Return list of input triggers for RPC device."""
triggers = []
key_ids = get_rpc_key_ids(device.config, "input")
for id_ in key_ids:
key = f"input:{id_}"
if not is_rpc_momentary_input(device.config, key):
continue
for trigger_type in RPC_INPUTS_EVENTS_TYPES:
subtype = f"button{id_+1}"
triggers.append((trigger_type, subtype))
return triggers

View File

@ -240,7 +240,7 @@ aiopylgtv==0.4.0
aiorecollect==1.0.8 aiorecollect==1.0.8
# homeassistant.components.shelly # homeassistant.components.shelly
aioshelly==0.6.4 aioshelly==1.0.1
# homeassistant.components.switcher_kis # homeassistant.components.switcher_kis
aioswitcher==2.0.6 aioswitcher==2.0.6

View File

@ -164,7 +164,7 @@ aiopylgtv==0.4.0
aiorecollect==1.0.8 aiorecollect==1.0.8
# homeassistant.components.shelly # homeassistant.components.shelly
aioshelly==0.6.4 aioshelly==1.0.1
# homeassistant.components.switcher_kis # homeassistant.components.switcher_kis
aioswitcher==2.0.6 aioswitcher==2.0.6

View File

@ -3,12 +3,13 @@ from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
from homeassistant.components.shelly import ShellyDeviceWrapper from homeassistant.components.shelly import BlockDeviceWrapper, RpcDeviceWrapper
from homeassistant.components.shelly.const import ( from homeassistant.components.shelly.const import (
COAP, BLOCK,
DATA_CONFIG_ENTRY, DATA_CONFIG_ENTRY,
DOMAIN, DOMAIN,
EVENT_SHELLY_CLICK, EVENT_SHELLY_CLICK,
RPC,
) )
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@ -54,6 +55,14 @@ MOCK_BLOCKS = [
), ),
] ]
MOCK_CONFIG = {
"input:0": {"id": 0, "type": "button"},
"switch:0": {"name": "test switch_0"},
"sys": {"ui_data": {}},
"wifi": {
"ap": {"ssid": "Test name"},
},
}
MOCK_SHELLY = { MOCK_SHELLY = {
"mac": "test-mac", "mac": "test-mac",
@ -62,6 +71,10 @@ MOCK_SHELLY = {
"num_outputs": 2, "num_outputs": 2,
} }
MOCK_STATUS = {
"switch:0": {"output": True},
}
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def mock_coap(): def mock_coap():
@ -104,6 +117,7 @@ async def coap_wrapper(hass):
blocks=MOCK_BLOCKS, blocks=MOCK_BLOCKS,
settings=MOCK_SETTINGS, settings=MOCK_SETTINGS,
shelly=MOCK_SHELLY, shelly=MOCK_SHELLY,
firmware_version="some fw string",
update=AsyncMock(), update=AsyncMock(),
initialized=True, initialized=True,
) )
@ -111,9 +125,44 @@ async def coap_wrapper(hass):
hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}}
hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {}
wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][
COAP BLOCK
] = ShellyDeviceWrapper(hass, config_entry, device) ] = 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,
event={},
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 return wrapper

View File

@ -20,9 +20,13 @@ DISCOVERY_INFO = {
"name": "shelly1pm-12345", "name": "shelly1pm-12345",
"properties": {"id": "shelly1pm-12345"}, "properties": {"id": "shelly1pm-12345"},
} }
MOCK_CONFIG = {
"wifi": {"ap": {"ssid": "Test name"}},
}
async def test_form(hass): @pytest.mark.parametrize("gen", [1, 2])
async def test_form(hass, gen):
"""Test we get the form.""" """Test we get the form."""
await setup.async_setup_component(hass, "persistent_notification", {}) await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -32,15 +36,25 @@ async def test_form(hass):
assert result["errors"] == {} assert result["errors"] == {}
with patch( with patch(
"aioshelly.get_info", "aioshelly.common.get_info",
return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False, "gen": gen},
), patch( ), patch(
"aioshelly.Device.create", "aioshelly.block_device.BlockDevice.create",
new=AsyncMock( new=AsyncMock(
return_value=Mock( return_value=Mock(
model="SHSW-1",
settings=MOCK_SETTINGS, settings=MOCK_SETTINGS,
) )
), ),
), patch(
"aioshelly.rpc_device.RpcDevice.create",
new=AsyncMock(
return_value=Mock(
model="SHSW-1",
config=MOCK_CONFIG,
shutdown=AsyncMock(),
)
),
), patch( ), patch(
"homeassistant.components.shelly.async_setup", return_value=True "homeassistant.components.shelly.async_setup", return_value=True
) as mock_setup, patch( ) as mock_setup, patch(
@ -59,6 +73,7 @@ async def test_form(hass):
"host": "1.1.1.1", "host": "1.1.1.1",
"model": "SHSW-1", "model": "SHSW-1",
"sleep_period": 0, "sleep_period": 0,
"gen": gen,
} }
assert len(mock_setup.mock_calls) == 1 assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@ -78,12 +93,13 @@ async def test_title_without_name(hass):
settings["device"] = settings["device"].copy() settings["device"] = settings["device"].copy()
settings["device"]["hostname"] = "shelly1pm-12345" settings["device"]["hostname"] = "shelly1pm-12345"
with patch( with patch(
"aioshelly.get_info", "aioshelly.common.get_info",
return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False},
), patch( ), patch(
"aioshelly.Device.create", "aioshelly.block_device.BlockDevice.create",
new=AsyncMock( new=AsyncMock(
return_value=Mock( return_value=Mock(
model="SHSW-1",
settings=settings, settings=settings,
) )
), ),
@ -105,6 +121,7 @@ async def test_title_without_name(hass):
"host": "1.1.1.1", "host": "1.1.1.1",
"model": "SHSW-1", "model": "SHSW-1",
"sleep_period": 0, "sleep_period": 0,
"gen": 1,
} }
assert len(mock_setup.mock_calls) == 1 assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@ -119,7 +136,7 @@ async def test_form_auth(hass):
assert result["errors"] == {} assert result["errors"] == {}
with patch( with patch(
"aioshelly.get_info", "aioshelly.common.get_info",
return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True}, return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True},
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
@ -131,9 +148,10 @@ async def test_form_auth(hass):
assert result["errors"] == {} assert result["errors"] == {}
with patch( with patch(
"aioshelly.Device.create", "aioshelly.block_device.BlockDevice.create",
new=AsyncMock( new=AsyncMock(
return_value=Mock( return_value=Mock(
model="SHSW-1",
settings=MOCK_SETTINGS, settings=MOCK_SETTINGS,
) )
), ),
@ -155,6 +173,7 @@ async def test_form_auth(hass):
"host": "1.1.1.1", "host": "1.1.1.1",
"model": "SHSW-1", "model": "SHSW-1",
"sleep_period": 0, "sleep_period": 0,
"gen": 1,
"username": "test username", "username": "test username",
"password": "test password", "password": "test password",
} }
@ -172,7 +191,7 @@ async def test_form_errors_get_info(hass, error):
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
with patch("aioshelly.get_info", side_effect=exc): with patch("aioshelly.common.get_info", side_effect=exc):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{"host": "1.1.1.1"}, {"host": "1.1.1.1"},
@ -193,8 +212,10 @@ async def test_form_errors_test_connection(hass, error):
) )
with patch( with patch(
"aioshelly.get_info", return_value={"mac": "test-mac", "auth": False} "aioshelly.common.get_info", return_value={"mac": "test-mac", "auth": False}
), patch("aioshelly.Device.create", new=AsyncMock(side_effect=exc)): ), patch(
"aioshelly.block_device.BlockDevice.create", new=AsyncMock(side_effect=exc)
):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{"host": "1.1.1.1"}, {"host": "1.1.1.1"},
@ -217,7 +238,7 @@ async def test_form_already_configured(hass):
) )
with patch( with patch(
"aioshelly.get_info", "aioshelly.common.get_info",
return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False},
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
@ -252,12 +273,13 @@ async def test_user_setup_ignored_device(hass):
settings["fw"] = "20201124-092534/v1.9.0@57ac4ad8" settings["fw"] = "20201124-092534/v1.9.0@57ac4ad8"
with patch( with patch(
"aioshelly.get_info", "aioshelly.common.get_info",
return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False},
), patch( ), patch(
"aioshelly.Device.create", "aioshelly.block_device.BlockDevice.create",
new=AsyncMock( new=AsyncMock(
return_value=Mock( return_value=Mock(
model="SHSW-1",
settings=settings, settings=settings,
) )
), ),
@ -287,7 +309,10 @@ async def test_form_firmware_unsupported(hass):
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
with patch("aioshelly.get_info", side_effect=aioshelly.FirmwareUnsupported): with patch(
"aioshelly.common.get_info",
side_effect=aioshelly.exceptions.FirmwareUnsupported,
):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{"host": "1.1.1.1"}, {"host": "1.1.1.1"},
@ -313,14 +338,17 @@ async def test_form_auth_errors_test_connection(hass, error):
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
with patch("aioshelly.get_info", return_value={"mac": "test-mac", "auth": True}): with patch(
"aioshelly.common.get_info",
return_value={"mac": "test-mac", "auth": True},
):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{"host": "1.1.1.1"}, {"host": "1.1.1.1"},
) )
with patch( with patch(
"aioshelly.Device.create", "aioshelly.block_device.BlockDevice.create",
new=AsyncMock(side_effect=exc), new=AsyncMock(side_effect=exc),
): ):
result3 = await hass.config_entries.flow.async_configure( result3 = await hass.config_entries.flow.async_configure(
@ -336,12 +364,13 @@ async def test_zeroconf(hass):
await setup.async_setup_component(hass, "persistent_notification", {}) await setup.async_setup_component(hass, "persistent_notification", {})
with patch( with patch(
"aioshelly.get_info", "aioshelly.common.get_info",
return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False},
), patch( ), patch(
"aioshelly.Device.create", "aioshelly.block_device.BlockDevice.create",
new=AsyncMock( new=AsyncMock(
return_value=Mock( return_value=Mock(
model="SHSW-1",
settings=MOCK_SETTINGS, settings=MOCK_SETTINGS,
) )
), ),
@ -378,6 +407,7 @@ async def test_zeroconf(hass):
"host": "1.1.1.1", "host": "1.1.1.1",
"model": "SHSW-1", "model": "SHSW-1",
"sleep_period": 0, "sleep_period": 0,
"gen": 1,
} }
assert len(mock_setup.mock_calls) == 1 assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@ -388,7 +418,7 @@ async def test_zeroconf_sleeping_device(hass):
await setup.async_setup_component(hass, "persistent_notification", {}) await setup.async_setup_component(hass, "persistent_notification", {})
with patch( with patch(
"aioshelly.get_info", "aioshelly.common.get_info",
return_value={ return_value={
"mac": "test-mac", "mac": "test-mac",
"type": "SHSW-1", "type": "SHSW-1",
@ -396,9 +426,10 @@ async def test_zeroconf_sleeping_device(hass):
"sleep_mode": True, "sleep_mode": True,
}, },
), patch( ), patch(
"aioshelly.Device.create", "aioshelly.block_device.BlockDevice.create",
new=AsyncMock( new=AsyncMock(
return_value=Mock( return_value=Mock(
model="SHSW-1",
settings={ settings={
"name": "Test name", "name": "Test name",
"device": { "device": {
@ -442,6 +473,7 @@ async def test_zeroconf_sleeping_device(hass):
"host": "1.1.1.1", "host": "1.1.1.1",
"model": "SHSW-1", "model": "SHSW-1",
"sleep_period": 600, "sleep_period": 600,
"gen": 1,
} }
assert len(mock_setup.mock_calls) == 1 assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@ -460,7 +492,7 @@ async def test_zeroconf_sleeping_device_error(hass, error):
await setup.async_setup_component(hass, "persistent_notification", {}) await setup.async_setup_component(hass, "persistent_notification", {})
with patch( with patch(
"aioshelly.get_info", "aioshelly.common.get_info",
return_value={ return_value={
"mac": "test-mac", "mac": "test-mac",
"type": "SHSW-1", "type": "SHSW-1",
@ -468,7 +500,7 @@ async def test_zeroconf_sleeping_device_error(hass, error):
"sleep_mode": True, "sleep_mode": True,
}, },
), patch( ), patch(
"aioshelly.Device.create", "aioshelly.block_device.BlockDevice.create",
new=AsyncMock(side_effect=exc), new=AsyncMock(side_effect=exc),
): ):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -489,7 +521,7 @@ async def test_zeroconf_already_configured(hass):
entry.add_to_hass(hass) entry.add_to_hass(hass)
with patch( with patch(
"aioshelly.get_info", "aioshelly.common.get_info",
return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False},
): ):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -506,7 +538,10 @@ async def test_zeroconf_already_configured(hass):
async def test_zeroconf_firmware_unsupported(hass): async def test_zeroconf_firmware_unsupported(hass):
"""Test we abort if device firmware is unsupported.""" """Test we abort if device firmware is unsupported."""
with patch("aioshelly.get_info", side_effect=aioshelly.FirmwareUnsupported): with patch(
"aioshelly.common.get_info",
side_effect=aioshelly.exceptions.FirmwareUnsupported,
):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
data=DISCOVERY_INFO, data=DISCOVERY_INFO,
@ -519,7 +554,7 @@ async def test_zeroconf_firmware_unsupported(hass):
async def test_zeroconf_cannot_connect(hass): async def test_zeroconf_cannot_connect(hass):
"""Test we get the form.""" """Test we get the form."""
with patch("aioshelly.get_info", side_effect=asyncio.TimeoutError): with patch("aioshelly.common.get_info", side_effect=asyncio.TimeoutError):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
data=DISCOVERY_INFO, data=DISCOVERY_INFO,
@ -534,7 +569,7 @@ async def test_zeroconf_require_auth(hass):
await setup.async_setup_component(hass, "persistent_notification", {}) await setup.async_setup_component(hass, "persistent_notification", {})
with patch( with patch(
"aioshelly.get_info", "aioshelly.common.get_info",
return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True}, return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True},
): ):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -546,9 +581,10 @@ async def test_zeroconf_require_auth(hass):
assert result["errors"] == {} assert result["errors"] == {}
with patch( with patch(
"aioshelly.Device.create", "aioshelly.block_device.BlockDevice.create",
new=AsyncMock( new=AsyncMock(
return_value=Mock( return_value=Mock(
model="SHSW-1",
settings=MOCK_SETTINGS, settings=MOCK_SETTINGS,
) )
), ),
@ -570,6 +606,7 @@ async def test_zeroconf_require_auth(hass):
"host": "1.1.1.1", "host": "1.1.1.1",
"model": "SHSW-1", "model": "SHSW-1",
"sleep_period": 0, "sleep_period": 0,
"gen": 1,
"username": "test username", "username": "test username",
"password": "test password", "password": "test password",
} }

View File

@ -8,11 +8,11 @@ from homeassistant.components import automation
from homeassistant.components.device_automation.exceptions import ( from homeassistant.components.device_automation.exceptions import (
InvalidDeviceAutomationConfig, InvalidDeviceAutomationConfig,
) )
from homeassistant.components.shelly import ShellyDeviceWrapper from homeassistant.components.shelly import BlockDeviceWrapper
from homeassistant.components.shelly.const import ( from homeassistant.components.shelly.const import (
ATTR_CHANNEL, ATTR_CHANNEL,
ATTR_CLICK_TYPE, ATTR_CLICK_TYPE,
COAP, BLOCK,
CONF_SUBTYPE, CONF_SUBTYPE,
DATA_CONFIG_ENTRY, DATA_CONFIG_ENTRY,
DOMAIN, DOMAIN,
@ -30,8 +30,8 @@ from tests.common import (
) )
async def test_get_triggers(hass, coap_wrapper): async def test_get_triggers_block_device(hass, coap_wrapper):
"""Test we get the expected triggers from a shelly.""" """Test we get the expected triggers from a shelly block device."""
assert coap_wrapper assert coap_wrapper
expected_triggers = [ expected_triggers = [
{ {
@ -57,6 +57,54 @@ async def test_get_triggers(hass, coap_wrapper):
assert_lists_same(triggers, expected_triggers) assert_lists_same(triggers, expected_triggers)
async def test_get_triggers_rpc_device(hass, rpc_wrapper):
"""Test we get the expected triggers from a shelly RPC device."""
assert rpc_wrapper
expected_triggers = [
{
CONF_PLATFORM: "device",
CONF_DEVICE_ID: rpc_wrapper.device_id,
CONF_DOMAIN: DOMAIN,
CONF_TYPE: "btn_down",
CONF_SUBTYPE: "button1",
},
{
CONF_PLATFORM: "device",
CONF_DEVICE_ID: rpc_wrapper.device_id,
CONF_DOMAIN: DOMAIN,
CONF_TYPE: "btn_up",
CONF_SUBTYPE: "button1",
},
{
CONF_PLATFORM: "device",
CONF_DEVICE_ID: rpc_wrapper.device_id,
CONF_DOMAIN: DOMAIN,
CONF_TYPE: "single_push",
CONF_SUBTYPE: "button1",
},
{
CONF_PLATFORM: "device",
CONF_DEVICE_ID: rpc_wrapper.device_id,
CONF_DOMAIN: DOMAIN,
CONF_TYPE: "double_push",
CONF_SUBTYPE: "button1",
},
{
CONF_PLATFORM: "device",
CONF_DEVICE_ID: rpc_wrapper.device_id,
CONF_DOMAIN: DOMAIN,
CONF_TYPE: "long_push",
CONF_SUBTYPE: "button1",
},
]
triggers = await async_get_device_automations(
hass, "trigger", rpc_wrapper.device_id
)
assert_lists_same(triggers, expected_triggers)
async def test_get_triggers_button(hass): async def test_get_triggers_button(hass):
"""Test we get the expected triggers from a shelly button.""" """Test we get the expected triggers from a shelly button."""
await async_setup_component(hass, "shelly", {}) await async_setup_component(hass, "shelly", {})
@ -79,10 +127,10 @@ async def test_get_triggers_button(hass):
hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}}
hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {}
coap_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ coap_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][
COAP BLOCK
] = ShellyDeviceWrapper(hass, config_entry, device) ] = BlockDeviceWrapper(hass, config_entry, device)
await coap_wrapper.async_setup() coap_wrapper.async_setup()
expected_triggers = [ expected_triggers = [
{ {
@ -136,8 +184,8 @@ async def test_get_triggers_for_invalid_device_id(hass, device_reg, coap_wrapper
await async_get_device_automations(hass, "trigger", invalid_device.id) await async_get_device_automations(hass, "trigger", invalid_device.id)
async def test_if_fires_on_click_event(hass, calls, coap_wrapper): async def test_if_fires_on_click_event_block_device(hass, calls, coap_wrapper):
"""Test for click_event trigger firing.""" """Test for click_event trigger firing for block device."""
assert coap_wrapper assert coap_wrapper
await setup.async_setup_component(hass, "persistent_notification", {}) await setup.async_setup_component(hass, "persistent_notification", {})
@ -175,8 +223,47 @@ async def test_if_fires_on_click_event(hass, calls, coap_wrapper):
assert calls[0].data["some"] == "test_trigger_single_click" assert calls[0].data["some"] == "test_trigger_single_click"
async def test_validate_trigger_config_no_device(hass, calls, coap_wrapper): async def test_if_fires_on_click_event_rpc_device(hass, calls, rpc_wrapper):
"""Test for click_event with no device.""" """Test for click_event trigger firing for rpc device."""
assert rpc_wrapper
await setup.async_setup_component(hass, "persistent_notification", {})
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: [
{
"trigger": {
CONF_PLATFORM: "device",
CONF_DOMAIN: DOMAIN,
CONF_DEVICE_ID: rpc_wrapper.device_id,
CONF_TYPE: "single_push",
CONF_SUBTYPE: "button1",
},
"action": {
"service": "test.automation",
"data_template": {"some": "test_trigger_single_push"},
},
},
]
},
)
message = {
CONF_DEVICE_ID: rpc_wrapper.device_id,
ATTR_CLICK_TYPE: "single_push",
ATTR_CHANNEL: 1,
}
hass.bus.async_fire(EVENT_SHELLY_CLICK, message)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0].data["some"] == "test_trigger_single_push"
async def test_validate_trigger_block_device_not_ready(hass, calls, coap_wrapper):
"""Test validate trigger config when block device is not ready."""
assert coap_wrapper assert coap_wrapper
await setup.async_setup_component(hass, "persistent_notification", {}) await setup.async_setup_component(hass, "persistent_notification", {})
@ -189,7 +276,7 @@ async def test_validate_trigger_config_no_device(hass, calls, coap_wrapper):
"trigger": { "trigger": {
CONF_PLATFORM: "device", CONF_PLATFORM: "device",
CONF_DOMAIN: DOMAIN, CONF_DOMAIN: DOMAIN,
CONF_DEVICE_ID: "no_device", CONF_DEVICE_ID: "device_not_ready",
CONF_TYPE: "single", CONF_TYPE: "single",
CONF_SUBTYPE: "button1", CONF_SUBTYPE: "button1",
}, },
@ -201,7 +288,11 @@ async def test_validate_trigger_config_no_device(hass, calls, coap_wrapper):
] ]
}, },
) )
message = {CONF_DEVICE_ID: "no_device", ATTR_CLICK_TYPE: "single", ATTR_CHANNEL: 1} message = {
CONF_DEVICE_ID: "device_not_ready",
ATTR_CLICK_TYPE: "single",
ATTR_CHANNEL: 1,
}
hass.bus.async_fire(EVENT_SHELLY_CLICK, message) hass.bus.async_fire(EVENT_SHELLY_CLICK, message)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -209,6 +300,44 @@ async def test_validate_trigger_config_no_device(hass, calls, coap_wrapper):
assert calls[0].data["some"] == "test_trigger_single_click" assert calls[0].data["some"] == "test_trigger_single_click"
async def test_validate_trigger_rpc_device_not_ready(hass, calls, rpc_wrapper):
"""Test validate trigger config when RPC device is not ready."""
assert rpc_wrapper
await setup.async_setup_component(hass, "persistent_notification", {})
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: [
{
"trigger": {
CONF_PLATFORM: "device",
CONF_DOMAIN: DOMAIN,
CONF_DEVICE_ID: "device_not_ready",
CONF_TYPE: "single_push",
CONF_SUBTYPE: "button1",
},
"action": {
"service": "test.automation",
"data_template": {"some": "test_trigger_single_push"},
},
},
]
},
)
message = {
CONF_DEVICE_ID: "device_not_ready",
ATTR_CLICK_TYPE: "single_push",
ATTR_CHANNEL: 1,
}
hass.bus.async_fire(EVENT_SHELLY_CLICK, message)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0].data["some"] == "test_trigger_single_push"
async def test_validate_trigger_invalid_triggers(hass, coap_wrapper): async def test_validate_trigger_invalid_triggers(hass, coap_wrapper):
"""Test for click_event with invalid triggers.""" """Test for click_event with invalid triggers."""
assert coap_wrapper assert coap_wrapper

View File

@ -13,8 +13,8 @@ from homeassistant.setup import async_setup_component
from tests.components.logbook.test_init import MockLazyEventPartialState from tests.components.logbook.test_init import MockLazyEventPartialState
async def test_humanify_shelly_click_event(hass, coap_wrapper): async def test_humanify_shelly_click_event_block_device(hass, coap_wrapper):
"""Test humanifying Shelly click event.""" """Test humanifying Shelly click event for block device."""
assert coap_wrapper assert coap_wrapper
hass.config.components.add("recorder") hass.config.components.add("recorder")
assert await async_setup_component(hass, "logbook", {}) assert await async_setup_component(hass, "logbook", {})
@ -51,12 +51,63 @@ async def test_humanify_shelly_click_event(hass, coap_wrapper):
assert event1["name"] == "Shelly" assert event1["name"] == "Shelly"
assert event1["domain"] == DOMAIN assert event1["domain"] == DOMAIN
assert ( assert (
event1["message"] == "'single' click event for Test name channel 1 was fired." event1["message"]
== "'single' click event for Test name channel 1 Input was fired."
) )
assert event2["name"] == "Shelly" assert event2["name"] == "Shelly"
assert event2["domain"] == DOMAIN assert event2["domain"] == DOMAIN
assert ( assert (
event2["message"] event2["message"]
== "'long' click event for shellyswitch25-12345678 channel 2 was fired." == "'long' click event for shellyswitch25-12345678 channel 2 Input was fired."
)
async def test_humanify_shelly_click_event_rpc_device(hass, rpc_wrapper):
"""Test humanifying Shelly click event for rpc device."""
assert rpc_wrapper
hass.config.components.add("recorder")
assert await async_setup_component(hass, "logbook", {})
entity_attr_cache = logbook.EntityAttributeCache(hass)
event1, event2 = list(
logbook.humanify(
hass,
[
MockLazyEventPartialState(
EVENT_SHELLY_CLICK,
{
ATTR_DEVICE_ID: rpc_wrapper.device_id,
ATTR_DEVICE: "shellyplus1pm-12345678",
ATTR_CLICK_TYPE: "single_push",
ATTR_CHANNEL: 1,
},
),
MockLazyEventPartialState(
EVENT_SHELLY_CLICK,
{
ATTR_DEVICE_ID: "no_device_id",
ATTR_DEVICE: "shellypro4pm-12345678",
ATTR_CLICK_TYPE: "btn_down",
ATTR_CHANNEL: 2,
},
),
],
entity_attr_cache,
{},
)
)
assert event1["name"] == "Shelly"
assert event1["domain"] == DOMAIN
assert (
event1["message"]
== "'single_push' click event for test switch_0 Input was fired."
)
assert event2["name"] == "Shelly"
assert event2["domain"] == DOMAIN
assert (
event2["message"]
== "'btn_down' click event for shellypro4pm-12345678 channel 2 Input was fired."
) )

View File

@ -11,8 +11,8 @@ from homeassistant.const import (
RELAY_BLOCK_ID = 0 RELAY_BLOCK_ID = 0
async def test_services(hass, coap_wrapper): async def test_block_device_services(hass, coap_wrapper):
"""Test device turn on/off services.""" """Test block device turn on/off services."""
assert coap_wrapper assert coap_wrapper
hass.async_create_task( 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 assert hass.states.get("switch.test_name_channel_1").state == STATE_OFF
async def test_update(hass, coap_wrapper, monkeypatch): async def test_block_device_update(hass, coap_wrapper, monkeypatch):
"""Test device update.""" """Test block device update."""
assert coap_wrapper assert coap_wrapper
hass.async_create_task( 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 assert hass.states.get("switch.test_name_channel_1").state == STATE_ON
async def test_no_relay_blocks(hass, coap_wrapper, monkeypatch): async def test_block_device_no_relay_blocks(hass, coap_wrapper, monkeypatch):
"""Test device without relay blocks.""" """Test block device without relay blocks."""
assert coap_wrapper assert coap_wrapper
monkeypatch.setattr(coap_wrapper.device.blocks[RELAY_BLOCK_ID], "type", "roller") 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 assert hass.states.get("switch.test_name_channel_1") is None
async def test_device_mode_roller(hass, coap_wrapper, monkeypatch): async def test_block_device_mode_roller(hass, coap_wrapper, monkeypatch):
"""Test switch device in roller mode.""" """Test block device in roller mode."""
assert coap_wrapper assert coap_wrapper
monkeypatch.setitem(coap_wrapper.device.settings, "mode", "roller") 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() await hass.async_block_till_done()
assert hass.states.get("switch.test_name_channel_1") is None 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