core/homeassistant/components/shelly/coordinator.py

664 lines
24 KiB
Python

"""Coordinators for the Shelly integration."""
from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from datetime import timedelta
from typing import Any, cast
import aioshelly
from aioshelly.ble import async_ensure_ble_enabled, async_stop_scanner
from aioshelly.block_device import BlockDevice
from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError
from aioshelly.rpc_device import RpcDevice, UpdateType
from awesomeversion import AwesomeVersion
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers import device_registry
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .bluetooth import async_connect_scanner
from .const import (
ATTR_CHANNEL,
ATTR_CLICK_TYPE,
ATTR_DEVICE,
ATTR_GENERATION,
BATTERY_DEVICES_WITH_PERMANENT_CONNECTION,
BLE_MIN_VERSION,
CONF_BLE_SCANNER_MODE,
CONF_SLEEP_PERIOD,
DATA_CONFIG_ENTRY,
DOMAIN,
DUAL_MODE_LIGHT_MODELS,
ENTRY_RELOAD_COOLDOWN,
EVENT_SHELLY_CLICK,
INPUTS_EVENTS_DICT,
LOGGER,
MODELS_SUPPORTING_LIGHT_EFFECTS,
REST_SENSORS_UPDATE_INTERVAL,
RPC_INPUTS_EVENTS_TYPES,
RPC_RECONNECT_INTERVAL,
RPC_SENSORS_POLLING_INTERVAL,
SHBTN_MODELS,
SLEEP_PERIOD_MULTIPLIER,
UPDATE_PERIOD_MULTIPLIER,
BLEScannerMode,
)
from .utils import (
device_update_info,
get_block_device_name,
get_rpc_device_name,
get_rpc_device_wakeup_period,
)
@dataclass
class ShellyEntryData:
"""Class for sharing data within a given config entry."""
block: ShellyBlockCoordinator | None = None
device: BlockDevice | RpcDevice | None = None
rest: ShellyRestCoordinator | None = None
rpc: ShellyRpcCoordinator | None = None
rpc_poll: ShellyRpcPollingCoordinator | None = None
def get_entry_data(hass: HomeAssistant) -> dict[str, ShellyEntryData]:
"""Return Shelly entry data for a given config entry."""
return cast(dict[str, ShellyEntryData], hass.data[DOMAIN][DATA_CONFIG_ENTRY])
class ShellyBlockCoordinator(DataUpdateCoordinator[None]):
"""Coordinator for a Shelly block based device."""
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, device: BlockDevice
) -> None:
"""Initialize the Shelly block device coordinator."""
self.device_id: str | None = None
if sleep_period := entry.data[CONF_SLEEP_PERIOD]:
update_interval = SLEEP_PERIOD_MULTIPLIER * sleep_period
else:
update_interval = (
UPDATE_PERIOD_MULTIPLIER * device.settings["coiot"]["update_period"]
)
device_name = (
get_block_device_name(device) if device.initialized else entry.title
)
super().__init__(
hass,
LOGGER,
name=device_name,
update_interval=timedelta(seconds=update_interval),
)
self.hass = hass
self.entry = entry
self.device = device
self._debounced_reload: Debouncer[Coroutine[Any, Any, None]] = Debouncer(
hass,
LOGGER,
cooldown=ENTRY_RELOAD_COOLDOWN,
immediate=False,
function=self._async_reload_entry,
)
entry.async_on_unload(self._debounced_reload.async_cancel)
self._last_cfg_changed: int | None = None
self._last_mode: str | None = None
self._last_effect: int | None = None
entry.async_on_unload(
self.async_add_listener(self._async_device_updates_handler)
)
self._last_input_events_count: dict = {}
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop)
)
async def _async_reload_entry(self) -> None:
"""Reload entry."""
LOGGER.debug("Reloading entry %s", self.name)
await self.hass.config_entries.async_reload(self.entry.entry_id)
@callback
def _async_device_updates_handler(self) -> None:
"""Handle device updates."""
if not self.device.initialized:
return
assert self.device.blocks
# 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:
for block in self.device.blocks:
if block.type != "device":
continue
if len(block.wakeupEvent) == 1 and block.wakeupEvent[0] == "button":
self._last_input_events_count[1] = -1
break
# Check for input events and config change
cfg_changed = 0
for block in self.device.blocks:
if block.type == "device":
cfg_changed = block.cfgChanged
# For dual mode bulbs ignore change if it is due to mode/effect change
if self.model in DUAL_MODE_LIGHT_MODELS:
if "mode" in block.sensor_ids:
if self._last_mode != block.mode:
self._last_cfg_changed = None
self._last_mode = block.mode
if self.model in MODELS_SUPPORTING_LIGHT_EFFECTS:
if "effect" in block.sensor_ids:
if self._last_effect != block.effect:
self._last_cfg_changed = None
self._last_effect = block.effect
if (
"inputEvent" not in block.sensor_ids
or "inputEventCnt" not in block.sensor_ids
):
LOGGER.debug("Skipping non-input event block %s", block.description)
continue
channel = int(block.channel or 0) + 1
event_type = block.inputEvent
last_event_count = self._last_input_events_count.get(channel)
self._last_input_events_count[channel] = block.inputEventCnt
if (
last_event_count is None
or last_event_count == block.inputEventCnt
or event_type == ""
):
LOGGER.debug("Skipping block event %s", event_type)
continue
if event_type in INPUTS_EVENTS_DICT:
self.hass.bus.async_fire(
EVENT_SHELLY_CLICK,
{
ATTR_DEVICE_ID: self.device_id,
ATTR_DEVICE: self.device.settings["device"]["hostname"],
ATTR_CHANNEL: channel,
ATTR_CLICK_TYPE: INPUTS_EVENTS_DICT[event_type],
ATTR_GENERATION: 1,
},
)
if self._last_cfg_changed is not None and cfg_changed > self._last_cfg_changed:
LOGGER.info(
"Config for %s changed, reloading entry in %s seconds",
self.name,
ENTRY_RELOAD_COOLDOWN,
)
self.hass.async_create_task(self._debounced_reload.async_call())
self._last_cfg_changed = cfg_changed
async def _async_update_data(self) -> None:
"""Fetch data."""
if sleep_period := self.entry.data.get(CONF_SLEEP_PERIOD):
# Sleeping device, no point polling it, just mark it unavailable
raise UpdateFailed(
f"Sleeping device did not update within {sleep_period} seconds interval"
)
LOGGER.debug("Polling Shelly Block Device - %s", self.name)
try:
await self.device.update()
except DeviceConnectionError as err:
raise UpdateFailed(f"Error fetching data: {repr(err)}") from err
except InvalidAuthError:
self.entry.async_start_reauth(self.hass)
else:
device_update_info(self.hass, self.device, self.entry)
@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)
@property
def sw_version(self) -> str:
"""Firmware version of the device."""
return self.device.firmware_version if self.device.initialized else ""
def async_setup(self) -> None:
"""Set up the coordinator."""
dev_reg = device_registry.async_get(self.hass)
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=self.sw_version,
hw_version=f"gen{self.device.gen} ({self.model})",
configuration_url=f"http://{self.entry.data[CONF_HOST]}",
)
self.device_id = entry.id
self.device.subscribe_updates(self.async_set_updated_data)
def shutdown(self) -> None:
"""Shutdown the coordinator."""
self.device.shutdown()
@callback
def _handle_ha_stop(self, _event: Event) -> None:
"""Handle Home Assistant stopping."""
LOGGER.debug("Stopping block device coordinator for %s", self.name)
self.shutdown()
class ShellyRestCoordinator(DataUpdateCoordinator[None]):
"""Coordinator for a Shelly REST device."""
def __init__(
self, hass: HomeAssistant, device: BlockDevice, entry: ConfigEntry
) -> None:
"""Initialize the Shelly REST device coordinator."""
if (
device.settings["device"]["type"]
in BATTERY_DEVICES_WITH_PERMANENT_CONNECTION
):
update_interval = (
SLEEP_PERIOD_MULTIPLIER * device.settings["coiot"]["update_period"]
)
else:
update_interval = REST_SENSORS_UPDATE_INTERVAL
super().__init__(
hass,
LOGGER,
name=get_block_device_name(device),
update_interval=timedelta(seconds=update_interval),
)
self.device = device
self.entry = entry
async def _async_update_data(self) -> None:
"""Fetch data."""
LOGGER.debug("REST update for %s", self.name)
try:
await self.device.update_status()
if self.device.status["uptime"] > 2 * REST_SENSORS_UPDATE_INTERVAL:
return
old_firmware = self.device.firmware_version
await self.device.update_shelly()
if self.device.firmware_version == old_firmware:
return
except DeviceConnectionError as err:
raise UpdateFailed(f"Error fetching data: {repr(err)}") from err
except InvalidAuthError:
self.entry.async_start_reauth(self.hass)
else:
device_update_info(self.hass, self.device, self.entry)
@property
def mac(self) -> str:
"""Mac address of the device."""
return cast(str, self.device.settings["device"]["mac"])
class ShellyRpcCoordinator(DataUpdateCoordinator[None]):
"""Coordinator for a Shelly RPC based device."""
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, device: RpcDevice
) -> None:
"""Initialize the Shelly RPC device coordinator."""
self.device_id: str | None = None
if sleep_period := entry.data[CONF_SLEEP_PERIOD]:
update_interval = SLEEP_PERIOD_MULTIPLIER * sleep_period
else:
update_interval = RPC_RECONNECT_INTERVAL
device_name = get_rpc_device_name(device) if device.initialized else entry.title
super().__init__(
hass,
LOGGER,
name=device_name,
update_interval=timedelta(seconds=update_interval),
)
self.entry = entry
self.device = device
self.connected = False
self._disconnected_callbacks: list[CALLBACK_TYPE] = []
self._connection_lock = asyncio.Lock()
self._event_listeners: list[Callable[[dict[str, Any]], None]] = []
self._debounced_reload: Debouncer[Coroutine[Any, Any, None]] = Debouncer(
hass,
LOGGER,
cooldown=ENTRY_RELOAD_COOLDOWN,
immediate=False,
function=self._async_reload_entry,
)
entry.async_on_unload(self._debounced_reload.async_cancel)
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop)
)
entry.async_on_unload(entry.add_update_listener(self._async_update_listener))
async def _async_reload_entry(self) -> None:
"""Reload entry."""
self._debounced_reload.async_cancel()
LOGGER.debug("Reloading entry %s", self.name)
await self.hass.config_entries.async_reload(self.entry.entry_id)
def update_sleep_period(self) -> bool:
"""Check device sleep period & update if changed."""
if (
not self.device.initialized
or not (wakeup_period := get_rpc_device_wakeup_period(self.device.status))
or wakeup_period == self.entry.data.get(CONF_SLEEP_PERIOD)
):
return False
data = {**self.entry.data}
data[CONF_SLEEP_PERIOD] = wakeup_period
self.hass.config_entries.async_update_entry(self.entry, data=data)
update_interval = SLEEP_PERIOD_MULTIPLIER * wakeup_period
self.update_interval = timedelta(seconds=update_interval)
return True
@callback
def async_subscribe_events(
self, event_callback: Callable[[dict[str, Any]], None]
) -> CALLBACK_TYPE:
"""Subscribe to events."""
def _unsubscribe() -> None:
self._event_listeners.remove(event_callback)
self._event_listeners.append(event_callback)
return _unsubscribe
async def _async_update_listener(
self, hass: HomeAssistant, entry: ConfigEntry
) -> None:
"""Reconfigure on update."""
async with self._connection_lock:
if self.connected:
self._async_run_disconnected_events()
await self._async_run_connected_events()
@callback
def _async_device_event_handler(self, event_data: dict[str, Any]) -> None:
"""Handle device events."""
events: list[dict[str, Any]] = event_data["events"]
for event in events:
event_type = event.get("event")
if event_type is None:
continue
for event_callback in self._event_listeners:
event_callback(event)
if event_type == "config_changed":
self.update_sleep_period()
LOGGER.info(
"Config for %s changed, reloading entry in %s seconds",
self.name,
ENTRY_RELOAD_COOLDOWN,
)
self.hass.async_create_task(self._debounced_reload.async_call())
elif event_type in RPC_INPUTS_EVENTS_TYPES:
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.update_sleep_period():
return
if sleep_period := self.entry.data.get(CONF_SLEEP_PERIOD):
# Sleeping device, no point polling it, just mark it unavailable
raise UpdateFailed(
f"Sleeping device did not update within {sleep_period} seconds interval"
)
if self.device.connected:
return
LOGGER.debug("Reconnecting to Shelly RPC Device - %s", self.name)
try:
await self.device.initialize()
device_update_info(self.hass, self.device, self.entry)
except DeviceConnectionError as err:
raise UpdateFailed(f"Device disconnected: {repr(err)}") from err
except InvalidAuthError:
self.entry.async_start_reauth(self.hass)
@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)
@property
def sw_version(self) -> str:
"""Firmware version of the device."""
return self.device.firmware_version if self.device.initialized else ""
async def _async_disconnected(self) -> None:
"""Handle device disconnected."""
async with self._connection_lock:
if not self.connected: # Already disconnected
return
self.connected = False
self._async_run_disconnected_events()
# Try to reconnect right away if hass is not stopping
if not self.hass.is_stopping:
await self.async_request_refresh()
@callback
def _async_run_disconnected_events(self) -> None:
"""Run disconnected events.
This will be executed on disconnect or when the config entry
is updated.
"""
for disconnected_callback in self._disconnected_callbacks:
disconnected_callback()
self._disconnected_callbacks.clear()
async def _async_connected(self) -> None:
"""Handle device connected."""
async with self._connection_lock:
if self.connected: # Already connected
return
self.connected = True
await self._async_run_connected_events()
async def _async_run_connected_events(self) -> None:
"""Run connected events.
This will be executed on connect or when the config entry
is updated.
"""
if not self.entry.data.get(CONF_SLEEP_PERIOD):
await self._async_connect_ble_scanner()
async def _async_connect_ble_scanner(self) -> None:
"""Connect BLE scanner."""
ble_scanner_mode = self.entry.options.get(
CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED
)
if ble_scanner_mode == BLEScannerMode.DISABLED:
await async_stop_scanner(self.device)
return
if AwesomeVersion(self.device.version) < BLE_MIN_VERSION:
LOGGER.error(
"BLE not supported on device %s with firmware %s; upgrade to %s",
self.name,
self.device.version,
BLE_MIN_VERSION,
)
return
if await async_ensure_ble_enabled(self.device):
# BLE enable required a reboot, don't bother connecting
# the scanner since it will be disconnected anyway
return
self._disconnected_callbacks.append(
await async_connect_scanner(self.hass, self, ble_scanner_mode)
)
@callback
def _async_handle_update(self, device_: RpcDevice, update_type: UpdateType) -> None:
"""Handle device update."""
if update_type is UpdateType.INITIALIZED:
self.hass.async_create_task(self._async_connected())
elif update_type is UpdateType.DISCONNECTED:
self.hass.async_create_task(self._async_disconnected())
elif update_type is UpdateType.STATUS:
self.async_set_updated_data(None)
elif update_type is UpdateType.EVENT and (event := self.device.event):
self._async_device_event_handler(event)
def async_setup(self) -> None:
"""Set up the coordinator."""
dev_reg = device_registry.async_get(self.hass)
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=self.sw_version,
hw_version=f"gen{self.device.gen} ({self.model})",
configuration_url=f"http://{self.entry.data[CONF_HOST]}",
)
self.device_id = entry.id
self.device.subscribe_updates(self._async_handle_update)
if self.device.initialized:
# If we are already initialized, we are connected
self.hass.async_create_task(self._async_connected())
async def shutdown(self) -> None:
"""Shutdown the coordinator."""
if self.device.connected:
await async_stop_scanner(self.device)
await self.device.shutdown()
await self._async_disconnected()
async def _handle_ha_stop(self, _event: Event) -> None:
"""Handle Home Assistant stopping."""
LOGGER.debug("Stopping RPC device coordinator for %s", self.name)
await self.shutdown()
class ShellyRpcPollingCoordinator(DataUpdateCoordinator[None]):
"""Polling coordinator for a Shelly RPC based device."""
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, device: RpcDevice
) -> None:
"""Initialize the RPC polling coordinator."""
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_SENSORS_POLLING_INTERVAL),
)
self.entry = entry
self.device = device
async def _async_update_data(self) -> None:
"""Fetch data."""
if not self.device.connected:
raise UpdateFailed("Device disconnected")
LOGGER.debug("Polling Shelly RPC Device - %s", self.name)
try:
await self.device.update_status()
except (DeviceConnectionError, RpcCallError) as err:
raise UpdateFailed(f"Device disconnected: {repr(err)}") from err
except InvalidAuthError:
self.entry.async_start_reauth(self.hass)
@property
def mac(self) -> str:
"""Mac address of the device."""
return cast(str, self.entry.unique_id)
def get_block_coordinator_by_device_id(
hass: HomeAssistant, device_id: str
) -> ShellyBlockCoordinator | None:
"""Get a Shelly block device coordinator for the given device id."""
dev_reg = device_registry.async_get(hass)
if device := dev_reg.async_get(device_id):
for config_entry in device.config_entries:
if not (entry_data := get_entry_data(hass).get(config_entry)):
continue
if coordinator := entry_data.block:
return coordinator
return None
def get_rpc_coordinator_by_device_id(
hass: HomeAssistant, device_id: str
) -> ShellyRpcCoordinator | None:
"""Get a Shelly RPC device coordinator for the given device id."""
dev_reg = device_registry.async_get(hass)
if device := dev_reg.async_get(device_id):
for config_entry in device.config_entries:
if not (entry_data := get_entry_data(hass).get(config_entry)):
continue
if coordinator := entry_data.rpc:
return coordinator
return None
async def async_reconnect_soon(
hass: HomeAssistant, entry: config_entries.ConfigEntry
) -> None:
"""Try to reconnect soon."""
if (
not hass.is_stopping
and entry.state == config_entries.ConfigEntryState.LOADED
and (entry_data := get_entry_data(hass).get(entry.entry_id))
and (coordinator := entry_data.rpc)
):
hass.async_create_task(coordinator.async_request_refresh())