From 40ccae3d07032e389d480df039950edb042df4b8 Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 23 Oct 2023 09:34:28 -0400 Subject: [PATCH] Add coordinator to Blink (#102536) --- homeassistant/components/blink/__init__.py | 58 ++++++-------- .../components/blink/alarm_control_panel.py | 79 ++++++++++--------- .../components/blink/binary_sensor.py | 36 ++++++--- homeassistant/components/blink/camera.py | 34 +++++--- homeassistant/components/blink/const.py | 1 - homeassistant/components/blink/coordinator.py | 33 ++++++++ homeassistant/components/blink/sensor.py | 45 +++++++---- 7 files changed, 173 insertions(+), 113 deletions(-) create mode 100644 homeassistant/components/blink/coordinator.py diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index e57b8e52729..89438c9c7c1 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -31,6 +31,7 @@ from .const import ( SERVICE_SAVE_VIDEO, SERVICE_SEND_PIN, ) +from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -84,6 +85,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: auth_data = deepcopy(dict(entry.data)) blink.auth = Auth(auth_data, no_prompt=True, session=session) blink.refresh_rate = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + coordinator = BlinkUpdateCoordinator(hass, blink) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator try: await blink.start() @@ -94,18 +98,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Attempting a reauth flow") raise ConfigEntryAuthFailed("Need 2FA for Blink") - hass.data[DOMAIN][entry.entry_id] = blink - if not blink.available: raise ConfigEntryNotReady await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) - await blink.refresh(force=True) async def blink_refresh(event_time=None): """Call blink to refresh info.""" - await hass.data[DOMAIN][entry.entry_id].refresh(force_cache=True) + await coordinator.api.refresh(force_cache=True) async def async_save_video(call): """Call save video service handler.""" @@ -118,8 +119,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def send_pin(call): """Call blink to send new pin.""" pin = call.data[CONF_PIN] - await hass.data[DOMAIN][entry.entry_id].auth.send_auth_key( - hass.data[DOMAIN][entry.entry_id], + await coordinator.api.auth.send_auth_key( + hass.data[DOMAIN][entry.entry_id].api, pin, ) @@ -154,26 +155,21 @@ def _async_import_options_from_data_if_missing( async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Blink entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + if not hass.data[DOMAIN]: + return True - if not unload_ok: - return False + hass.services.async_remove(DOMAIN, SERVICE_REFRESH) + hass.services.async_remove(DOMAIN, SERVICE_SAVE_VIDEO) + hass.services.async_remove(DOMAIN, SERVICE_SEND_PIN) - hass.data[DOMAIN].pop(entry.entry_id) - - if len(hass.data[DOMAIN]) != 0: - return True - - hass.services.async_remove(DOMAIN, SERVICE_REFRESH) - hass.services.async_remove(DOMAIN, SERVICE_SAVE_VIDEO) - hass.services.async_remove(DOMAIN, SERVICE_SEND_PIN) - - return True + return unload_ok async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" - blink: Blink = hass.data[DOMAIN][entry.entry_id] + blink: Blink = hass.data[DOMAIN][entry.entry_id].api blink.refresh_rate = entry.options[CONF_SCAN_INTERVAL] @@ -186,13 +182,12 @@ async def async_handle_save_video_service( if not hass.config.is_allowed_path(video_path): _LOGGER.error("Can't write %s, no access to path!", video_path) return - try: - all_cameras = hass.data[DOMAIN][entry.entry_id].cameras - if camera_name in all_cameras: + all_cameras = hass.data[DOMAIN][entry.entry_id].api.cameras + if camera_name in all_cameras: + try: await all_cameras[camera_name].video_to_file(video_path) - - except OSError as err: - _LOGGER.error("Can't write image to file: %s", err) + except OSError as err: + _LOGGER.error("Can't write image to file: %s", err) async def async_handle_save_recent_clips_service( @@ -204,10 +199,9 @@ async def async_handle_save_recent_clips_service( if not hass.config.is_allowed_path(clips_dir): _LOGGER.error("Can't write to directory %s, no access to path!", clips_dir) return - - try: - all_cameras = hass.data[DOMAIN][entry.entry_id].cameras - if camera_name in all_cameras: + all_cameras = hass.data[DOMAIN][entry.entry_id].api.cameras + if camera_name in all_cameras: + try: await all_cameras[camera_name].save_recent_clips(output_dir=clips_dir) - except OSError as err: - _LOGGER.error("Can't write recent clips to directory: %s", err) + except OSError as err: + _LOGGER.error("Can't write recent clips to directory: %s", err) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index 2249c9bf16f..c789d7cdd6f 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import logging -from blinkpy.blinkpy import Blink +from blinkpy.blinkpy import Blink, BlinkSyncModule from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, @@ -16,12 +16,14 @@ from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN +from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -32,25 +34,31 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Blink Alarm Control Panels.""" - data = hass.data[DOMAIN][config.entry_id] + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] sync_modules = [] - for sync_name, sync_module in data.sync.items(): - sync_modules.append(BlinkSyncModuleHA(data, sync_name, sync_module)) - async_add_entities(sync_modules, update_before_add=True) + for sync_name, sync_module in coordinator.api.sync.items(): + sync_modules.append(BlinkSyncModuleHA(coordinator, sync_name, sync_module)) + async_add_entities(sync_modules) -class BlinkSyncModuleHA(AlarmControlPanelEntity): +class BlinkSyncModuleHA( + CoordinatorEntity[BlinkUpdateCoordinator], AlarmControlPanelEntity +): """Representation of a Blink Alarm Control Panel.""" _attr_icon = ICON _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY - _attr_name = None _attr_has_entity_name = True + _attr_name = None - def __init__(self, data, name: str, sync) -> None: + def __init__( + self, coordinator: BlinkUpdateCoordinator, name: str, sync: BlinkSyncModule + ) -> None: """Initialize the alarm control panel.""" - self.data: Blink = data + super().__init__(coordinator) + self.api: Blink = coordinator.api + self._coordinator = coordinator self.sync = sync self._name: str = name self._attr_unique_id: str = sync.serial @@ -59,49 +67,42 @@ class BlinkSyncModuleHA(AlarmControlPanelEntity): name=f"{DOMAIN} {name}", manufacturer=DEFAULT_BRAND, ) + self._update_attr() - async def async_update(self) -> None: - """Update the state of the device.""" - if self.data.check_if_ok_to_update(): - _LOGGER.debug( - "Initiating a blink.refresh() from BlinkSyncModule('%s') (%s)", - self._name, - self.data, - ) - try: - await self.data.refresh(force=True) - self._attr_available = True - except asyncio.TimeoutError: - self._attr_available = False + @callback + def _handle_coordinator_update(self) -> None: + """Handle coordinator update.""" + self._update_attr() + super()._handle_coordinator_update() - _LOGGER.info("Updating State of Blink Alarm Control Panel '%s'", self._name) - - self.sync.attributes["network_info"] = self.data.networks + @callback + def _update_attr(self) -> None: + """Update attributes for alarm control panel.""" + self.sync.attributes["network_info"] = self.api.networks self.sync.attributes["associated_cameras"] = list(self.sync.cameras) self.sync.attributes[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION self._attr_extra_state_attributes = self.sync.attributes - - @property - def state(self) -> StateType: - """Return state of alarm.""" - return STATE_ALARM_ARMED_AWAY if self.sync.arm else STATE_ALARM_DISARMED + self._attr_state = ( + STATE_ALARM_ARMED_AWAY if self.sync.arm else STATE_ALARM_DISARMED + ) async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" try: await self.sync.async_arm(False) - await self.sync.refresh(force=True) - except asyncio.TimeoutError: - self._attr_available = False - self.async_write_ha_state() + except asyncio.TimeoutError as er: + raise HomeAssistantError("Blink failed to disarm camera") from er + + await self._coordinator.async_refresh() async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm command.""" try: await self.sync.async_arm(True) - await self.sync.refresh(force=True) - except asyncio.TimeoutError: - self._attr_available = False + except asyncio.TimeoutError as er: + raise HomeAssistantError("Blink failed to arm camera away") from er + + await self._coordinator.async_refresh() self.async_write_ha_state() diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 51df22dbf0e..65e454e4434 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -10,9 +10,10 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DEFAULT_BRAND, @@ -21,6 +22,7 @@ from .const import ( TYPE_CAMERA_ARMED, TYPE_MOTION_DETECTED, ) +from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -45,28 +47,31 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the blink binary sensors.""" - data = hass.data[DOMAIN][config.entry_id] + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] entities = [ - BlinkBinarySensor(data, camera, description) - for camera in data.cameras + BlinkBinarySensor(coordinator, camera, description) + for camera in coordinator.api.cameras for description in BINARY_SENSORS_TYPES ] async_add_entities(entities) -class BlinkBinarySensor(BinarySensorEntity): +class BlinkBinarySensor(CoordinatorEntity[BlinkUpdateCoordinator], BinarySensorEntity): """Representation of a Blink binary sensor.""" _attr_has_entity_name = True def __init__( - self, data, camera, description: BinarySensorEntityDescription + self, + coordinator: BlinkUpdateCoordinator, + camera, + description: BinarySensorEntityDescription, ) -> None: """Initialize the sensor.""" - self.data = data + super().__init__(coordinator) self.entity_description = description - self._camera = data.cameras[camera] + self._camera = coordinator.api.cameras[camera] self._attr_unique_id = f"{self._camera.serial}-{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._camera.serial)}, @@ -74,10 +79,17 @@ class BlinkBinarySensor(BinarySensorEntity): manufacturer=DEFAULT_BRAND, model=self._camera.camera_type, ) + self._update_attrs() - @property - def is_on(self) -> bool | None: - """Update sensor state.""" + @callback + def _handle_coordinator_update(self) -> None: + """Handle update from data coordinator.""" + self._update_attrs() + super()._handle_coordinator_update() + + @callback + def _update_attrs(self) -> None: + """Update attributes for binary sensor.""" is_on = self._camera.attributes[self.entity_description.key] _LOGGER.debug( "'%s' %s = %s", @@ -87,4 +99,4 @@ class BlinkBinarySensor(BinarySensorEntity): ) if self.entity_description.key == TYPE_BATTERY: is_on = is_on != "ok" - return is_on + self._attr_is_on = is_on diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index bf7af4fe619..4ff0ba86db9 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -12,11 +12,14 @@ from requests.exceptions import ChunkedEncodingError from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_BRAND, DOMAIN, SERVICE_TRIGGER +from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -28,9 +31,10 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up a Blink Camera.""" - data = hass.data[DOMAIN][config.entry_id] + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] entities = [ - BlinkCamera(data, name, camera) for name, camera in data.cameras.items() + BlinkCamera(coordinator, name, camera) + for name, camera in coordinator.api.cameras.items() ] async_add_entities(entities) @@ -39,16 +43,17 @@ async def async_setup_entry( platform.async_register_entity_service(SERVICE_TRIGGER, {}, "trigger_camera") -class BlinkCamera(Camera): +class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): """An implementation of a Blink Camera.""" _attr_has_entity_name = True _attr_name = None - def __init__(self, data, name, camera) -> None: + def __init__(self, coordinator: BlinkUpdateCoordinator, name, camera) -> None: """Initialize a camera.""" - super().__init__() - self.data = data + super().__init__(coordinator) + Camera.__init__(self) + self._coordinator = coordinator self._camera = camera self._attr_unique_id = f"{camera.serial}-camera" self._attr_device_info = DeviceInfo( @@ -68,17 +73,22 @@ class BlinkCamera(Camera): """Enable motion detection for the camera.""" try: await self._camera.async_arm(True) - await self.data.refresh(force=True) - except asyncio.TimeoutError: - self._attr_available = False + + except asyncio.TimeoutError as er: + raise HomeAssistantError("Blink failed to arm camera") from er + + self._camera.motion_enabled = True + await self._coordinator.async_refresh() async def async_disable_motion_detection(self) -> None: """Disable motion detection for the camera.""" try: await self._camera.async_arm(False) - await self.data.refresh(force=True) - except asyncio.TimeoutError: - self._attr_available = False + except asyncio.TimeoutError as er: + raise HomeAssistantError("Blink failed to disarm camera") from er + + self._camera.motion_enabled = False + await self._coordinator.async_refresh() @property def motion_detection_enabled(self) -> bool: diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index d58920562f4..7de42a80efc 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -7,7 +7,6 @@ DEVICE_ID = "Home Assistant" CONF_MIGRATE = "migrate" CONF_CAMERA = "camera" CONF_ALARM_CONTROL_PANEL = "alarm_control_panel" - DEFAULT_BRAND = "Blink" DEFAULT_ATTRIBUTION = "Data provided by immedia-semi.com" DEFAULT_SCAN_INTERVAL = 300 diff --git a/homeassistant/components/blink/coordinator.py b/homeassistant/components/blink/coordinator.py new file mode 100644 index 00000000000..d3f7551e1b2 --- /dev/null +++ b/homeassistant/components/blink/coordinator.py @@ -0,0 +1,33 @@ +"""Blink Coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from blinkpy.blinkpy import Blink + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class BlinkUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """BlinkUpdateCoordinator - In charge of downloading the data for a site.""" + + def __init__(self, hass: HomeAssistant, api: Blink) -> None: + """Initialize the data service.""" + self.api = api + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Async update wrapper.""" + return await self.api.refresh(force=True) diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index c979c9b6a53..9453d3b6d6b 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -1,8 +1,6 @@ """Support for Blink system camera sensors.""" from __future__ import annotations -from datetime import date, datetime -from decimal import Decimal import logging from homeassistant.components.sensor import ( @@ -17,12 +15,13 @@ from homeassistant.const import ( EntityCategory, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_BRAND, DOMAIN, TYPE_TEMPERATURE, TYPE_WIFI_STRENGTH +from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -49,26 +48,32 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Initialize a Blink sensor.""" - data = hass.data[DOMAIN][config.entry_id] + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] entities = [ - BlinkSensor(data, camera, description) - for camera in data.cameras + BlinkSensor(coordinator, camera, description) + for camera in coordinator.api.cameras for description in SENSOR_TYPES ] async_add_entities(entities) -class BlinkSensor(SensorEntity): +class BlinkSensor(CoordinatorEntity[BlinkUpdateCoordinator], SensorEntity): """A Blink camera sensor.""" _attr_has_entity_name = True - def __init__(self, data, camera, description: SensorEntityDescription) -> None: + def __init__( + self, + coordinator: BlinkUpdateCoordinator, + camera, + description: SensorEntityDescription, + ) -> None: """Initialize sensors from Blink camera.""" + super().__init__(coordinator) self.entity_description = description - self.data = data - self._camera = data.cameras[camera] + + self._camera = coordinator.api.cameras[camera] self._attr_unique_id = f"{self._camera.serial}-{description.key}" self._sensor_key = ( "temperature_calibrated" @@ -81,12 +86,19 @@ class BlinkSensor(SensorEntity): manufacturer=DEFAULT_BRAND, model=self._camera.camera_type, ) + self._update_attr() - @property - def native_value(self) -> StateType | date | datetime | Decimal: - """Retrieve sensor data from the camera.""" + @callback + def _handle_coordinator_update(self) -> None: + """Handle coordinator update.""" + self._update_attr() + super()._handle_coordinator_update() + + @callback + def _update_attr(self) -> None: + """Update attributes for sensor.""" try: - native_value = self._camera.attributes[self._sensor_key] + self._attr_native_value = self._camera.attributes[self._sensor_key] _LOGGER.debug( "'%s' %s = %s", self._camera.attributes["name"], @@ -94,8 +106,7 @@ class BlinkSensor(SensorEntity): self._attr_native_value, ) except KeyError: - native_value = None + self._attr_native_value = None _LOGGER.error( "%s not a valid camera attribute. Did the API change?", self._sensor_key ) - return native_value