Skip to content

Commit

Permalink
Add support for Shelly virtual boolean component (home-assistant#11…
Browse files Browse the repository at this point in the history
…9932)

Co-authored-by: Maciej Bieniek <[email protected]>
  • Loading branch information
bieniu and bieniu authored Jul 10, 2024
1 parent 311b1e2 commit 70f05e5
Show file tree
Hide file tree
Showing 12 changed files with 448 additions and 7 deletions.
25 changes: 25 additions & 0 deletions homeassistant/components/shelly/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from aioshelly.const import RPC_GENERATIONS

from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_PLATFORM,
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
Expand All @@ -33,7 +34,9 @@
async_setup_entry_rpc,
)
from .utils import (
async_remove_orphaned_virtual_entities,
get_device_entry_gen,
get_virtual_component_ids,
is_block_momentary_input,
is_rpc_momentary_input,
)
Expand Down Expand Up @@ -215,6 +218,11 @@ class RestBinarySensorDescription(RestEntityDescription, BinarySensorEntityDescr
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
"boolean": RpcBinarySensorDescription(
key="boolean",
sub_key="value",
has_entity_name=True,
),
}


Expand All @@ -234,9 +242,26 @@ async def async_setup_entry(
RpcSleepingBinarySensor,
)
else:
coordinator = config_entry.runtime_data.rpc
assert coordinator

async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_SENSORS, RpcBinarySensor
)

# the user can remove virtual components from the device configuration, so
# we need to remove orphaned entities
virtual_binary_sensor_ids = get_virtual_component_ids(
coordinator.device.config, BINARY_SENSOR_PLATFORM
)
async_remove_orphaned_virtual_entities(
hass,
config_entry.entry_id,
coordinator.mac,
BINARY_SENSOR_PLATFORM,
"boolean",
virtual_binary_sensor_ids,
)
return

if config_entry.data[CONF_SLEEP_PERIOD]:
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/components/shelly/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,8 @@ class BLEScannerMode(StrEnum):
CONF_GEN = "gen"

SHELLY_PLUS_RGBW_CHANNELS = 4

VIRTUAL_COMPONENTS_MAP = {
"binary_sensor": {"type": "boolean", "mode": "label"},
"switch": {"type": "boolean", "mode": "toggle"},
}
3 changes: 2 additions & 1 deletion homeassistant/components/shelly/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ def _async_device_event_handler(self, event_data: dict[str, Any]) -> None:
for event_callback in self._event_listeners:
event_callback(event)

if event_type == "config_changed":
if event_type in ("component_added", "component_removed", "config_changed"):
self.update_sleep_period()
LOGGER.info(
"Config for %s changed, reloading entry in %s seconds",
Expand Down Expand Up @@ -739,6 +739,7 @@ async def _async_update_data(self) -> None:
LOGGER.debug("Polling Shelly RPC Device - %s", self.name)
try:
await self.device.update_status()
await self.device.get_dynamic_components()
except (DeviceConnectionError, RpcCallError) as err:
raise UpdateFailed(f"Device disconnected: {err!r}") from err
except InvalidAuthError:
Expand Down
2 changes: 2 additions & 0 deletions homeassistant/components/shelly/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,8 @@ def __init__(
self._attr_unique_id = f"{super().unique_id}-{attribute}"
self._attr_name = get_rpc_entity_name(coordinator.device, key, description.name)
self._last_value = None
id_key = key.split(":")[-1]
self._id = int(id_key) if id_key.isnumeric() else None

@property
def sub_status(self) -> Any:
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/shelly/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"iot_class": "local_push",
"loggers": ["aioshelly"],
"quality_scale": "platinum",
"requirements": ["aioshelly==11.0.0"],
"requirements": ["aioshelly==11.1.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",
Expand Down
64 changes: 63 additions & 1 deletion homeassistant/components/shelly/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
from aioshelly.block_device import Block
from aioshelly.const import MODEL_2, MODEL_25, MODEL_WALL_DISPLAY, RPC_GENERATIONS

from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.components.switch import (
DOMAIN as SWITCH_PLATFORM,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
Expand All @@ -19,15 +23,20 @@
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
BlockEntityDescription,
RpcEntityDescription,
ShellyBlockEntity,
ShellyRpcAttributeEntity,
ShellyRpcEntity,
ShellySleepingBlockAttributeEntity,
async_setup_entry_attribute_entities,
async_setup_rpc_attribute_entities,
)
from .utils import (
async_remove_orphaned_virtual_entities,
async_remove_shelly_entity,
get_device_entry_gen,
get_rpc_key_ids,
get_virtual_component_ids,
is_block_channel_type_light,
is_rpc_channel_type_light,
is_rpc_thermostat_internal_actuator,
Expand All @@ -47,6 +56,17 @@ class BlockSwitchDescription(BlockEntityDescription, SwitchEntityDescription):
)


@dataclass(frozen=True, kw_only=True)
class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription):
"""Class to describe a RPC virtual switch."""


RPC_VIRTUAL_SWITCH = RpcSwitchDescription(
key="boolean",
sub_key="value",
)


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
Expand Down Expand Up @@ -148,6 +168,28 @@ def async_setup_rpc_entry(
unique_id = f"{coordinator.mac}-switch:{id_}"
async_remove_shelly_entity(hass, "light", unique_id)

async_setup_rpc_attribute_entities(
hass,
config_entry,
async_add_entities,
{"boolean": RPC_VIRTUAL_SWITCH},
RpcVirtualSwitch,
)

# the user can remove virtual components from the device configuration, so we need
# to remove orphaned entities
virtual_switch_ids = get_virtual_component_ids(
coordinator.device.config, SWITCH_PLATFORM
)
async_remove_orphaned_virtual_entities(
hass,
config_entry.entry_id,
coordinator.mac,
SWITCH_PLATFORM,
"boolean",
virtual_switch_ids,
)

if not switch_ids:
return

Expand Down Expand Up @@ -255,3 +297,23 @@ async def async_turn_on(self, **kwargs: Any) -> None:
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off relay."""
await self.call_rpc("Switch.Set", {"id": self._id, "on": False})


class RpcVirtualSwitch(ShellyRpcAttributeEntity, SwitchEntity):
"""Entity that controls a virtual boolean component on RPC based Shelly devices."""

entity_description: RpcSwitchDescription
_attr_has_entity_name = True

@property
def is_on(self) -> bool:
"""If switch is on."""
return bool(self.attribute_value)

async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on relay."""
await self.call_rpc("Boolean.Set", {"id": self._id, "value": True})

async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off relay."""
await self.call_rpc("Boolean.Set", {"id": self._id, "value": False})
56 changes: 56 additions & 0 deletions homeassistant/components/shelly/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from datetime import datetime, timedelta
from ipaddress import IPv4Address
import re
from types import MappingProxyType
from typing import Any, cast

Expand Down Expand Up @@ -52,6 +53,7 @@
SHBTN_MODELS,
SHIX3_1_INPUTS_EVENTS_TYPES,
UPTIME_DEVIATION,
VIRTUAL_COMPONENTS_MAP,
)


Expand Down Expand Up @@ -321,6 +323,8 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str:
return f"{device_name} {key.replace(':', '_')}"
if key.startswith("em1"):
return f"{device_name} EM{key.split(':')[-1]}"
if key.startswith("boolean:"):
return key.replace(":", " ").title()
return device_name

return entity_name
Expand Down Expand Up @@ -497,3 +501,55 @@ def async_remove_shelly_rpc_entities(
def is_rpc_thermostat_mode(ident: int, status: dict[str, Any]) -> bool:
"""Return True if 'thermostat:<IDent>' is present in the status."""
return f"thermostat:{ident}" in status


def get_virtual_component_ids(config: dict[str, Any], platform: str) -> list[str]:
"""Return a list of virtual component IDs for a platform."""
component = VIRTUAL_COMPONENTS_MAP.get(platform)

if not component:
return []

return [
k
for k, v in config.items()
if k.startswith(component["type"])
and v["meta"]["ui"]["view"] == component["mode"]
]


@callback
def async_remove_orphaned_virtual_entities(
hass: HomeAssistant,
config_entry_id: str,
mac: str,
platform: str,
virt_comp_type: str,
virt_comp_ids: list[str],
) -> None:
"""Remove orphaned virtual entities."""
orphaned_entities = []
entity_reg = er.async_get(hass)
device_reg = dr.async_get(hass)

if not (
devices := device_reg.devices.get_devices_for_config_entry_id(config_entry_id)
):
return

device_id = devices[0].id
entities = er.async_entries_for_device(entity_reg, device_id, True)
for entity in entities:
if not entity.entity_id.startswith(platform):
continue
if virt_comp_type not in entity.unique_id:
continue
# we are looking for the component ID, e.g. boolean:201
if not (match := re.search(r"[a-z]+:\d+", entity.unique_id)):
continue
virt_comp_id = match.group()
if virt_comp_id not in virt_comp_ids:
orphaned_entities.append(f"{virt_comp_id}-{virt_comp_type}")

if orphaned_entities:
async_remove_shelly_rpc_entities(hass, platform, mac, orphaned_entities)
2 changes: 1 addition & 1 deletion requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0

# homeassistant.components.shelly
aioshelly==11.0.0
aioshelly==11.1.0

# homeassistant.components.skybell
aioskybell==22.7.0
Expand Down
2 changes: 1 addition & 1 deletion requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0

# homeassistant.components.shelly
aioshelly==11.0.0
aioshelly==11.1.0

# homeassistant.components.skybell
aioskybell==22.7.0
Expand Down
9 changes: 7 additions & 2 deletions tests/components/shelly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceEntry,
DeviceRegistry,
format_mac,
)
Expand Down Expand Up @@ -111,6 +112,7 @@ def register_entity(
unique_id: str,
config_entry: ConfigEntry | None = None,
capabilities: Mapping[str, Any] | None = None,
device_id: str | None = None,
) -> str:
"""Register enabled entity, return entity_id."""
entity_registry = er.async_get(hass)
Expand All @@ -122,6 +124,7 @@ def register_entity(
disabled_by=None,
config_entry=config_entry,
capabilities=capabilities,
device_id=device_id,
)
return f"{domain}.{object_id}"

Expand All @@ -145,9 +148,11 @@ def get_entity_state(hass: HomeAssistant, entity_id: str) -> str:
return entity.state


def register_device(device_registry: DeviceRegistry, config_entry: ConfigEntry) -> None:
def register_device(
device_registry: DeviceRegistry, config_entry: ConfigEntry
) -> DeviceEntry:
"""Register Shelly device."""
device_registry.async_get_or_create(
return device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(CONNECTION_NETWORK_MAC, format_mac(MOCK_MAC))},
)
Loading

0 comments on commit 70f05e5

Please sign in to comment.