From eaf6197d430435b03b4f3fcb3d32dfd938cb4bd8 Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 16 Oct 2023 07:41:45 -0400 Subject: [PATCH] Bump Blinkpy to 0.22.2 in Blink (#98571) --- homeassistant/components/blink/__init__.py | 86 +++++++++---------- .../components/blink/alarm_control_panel.py | 57 ++++++++---- .../components/blink/binary_sensor.py | 13 +-- homeassistant/components/blink/camera.py | 38 +++++--- homeassistant/components/blink/config_flow.py | 28 +++--- homeassistant/components/blink/manifest.json | 2 +- homeassistant/components/blink/sensor.py | 16 +++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/blink/test_config_flow.py | 7 +- 10 files changed, 144 insertions(+), 107 deletions(-) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index b94a77fbf18..534fff310e3 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -1,7 +1,9 @@ """Support for Blink Home Camera System.""" +import asyncio from copy import deepcopy import logging +from aiohttp import ClientError from blinkpy.auth import Auth from blinkpy.blinkpy import Blink import voluptuous as vol @@ -16,8 +18,9 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( DEFAULT_SCAN_INTERVAL, @@ -40,23 +43,7 @@ SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema( ) -def _blink_startup_wrapper(hass: HomeAssistant, entry: ConfigEntry) -> Blink: - """Startup wrapper for blink.""" - blink = Blink() - auth_data = deepcopy(dict(entry.data)) - blink.auth = Auth(auth_data, no_prompt=True) - blink.refresh_rate = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - - if blink.start(): - blink.setup_post_verify() - elif blink.auth.check_key_required(): - _LOGGER.debug("Attempting a reauth flow") - _reauth_flow_wrapper(hass, auth_data) - - return blink - - -def _reauth_flow_wrapper(hass, data): +async def _reauth_flow_wrapper(hass, data): """Reauth flow wrapper.""" hass.add_job( hass.config_entries.flow.async_init( @@ -79,10 +66,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data = {**entry.data} if entry.version == 1: data.pop("login_response", None) - await hass.async_add_executor_job(_reauth_flow_wrapper, hass, data) + await _reauth_flow_wrapper(hass, data) return False if entry.version == 2: - await hass.async_add_executor_job(_reauth_flow_wrapper, hass, data) + await _reauth_flow_wrapper(hass, data) return False return True @@ -92,19 +79,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) _async_import_options_from_data_if_missing(hass, entry) - hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( - _blink_startup_wrapper, hass, entry - ) + session = async_get_clientsession(hass) + blink = Blink(session=session) + 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) - if not hass.data[DOMAIN][entry.entry_id].available: + try: + await blink.start() + if blink.auth.check_key_required(): + _LOGGER.debug("Attempting a reauth flow") + raise ConfigEntryAuthFailed("Need 2FA for Blink") + except (ClientError, asyncio.TimeoutError) as ex: + raise ConfigEntryNotReady("Can not connect to host") from ex + + 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) - def blink_refresh(event_time=None): + async def blink_refresh(event_time=None): """Call blink to refresh info.""" - hass.data[DOMAIN][entry.entry_id].refresh(force_cache=True) + await hass.data[DOMAIN][entry.entry_id].refresh(force_cache=True) async def async_save_video(call): """Call save video service handler.""" @@ -114,10 +114,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Call save recent clips service handler.""" await async_handle_save_recent_clips_service(hass, entry, call) - def send_pin(call): + async def send_pin(call): """Call blink to send new pin.""" pin = call.data[CONF_PIN] - hass.data[DOMAIN][entry.entry_id].auth.send_auth_key( + await hass.data[DOMAIN][entry.entry_id].auth.send_auth_key( hass.data[DOMAIN][entry.entry_id], pin, ) @@ -176,27 +176,27 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: blink.refresh_rate = entry.options[CONF_SCAN_INTERVAL] -async def async_handle_save_video_service(hass, entry, call): +async def async_handle_save_video_service( + hass: HomeAssistant, entry: ConfigEntry, call +) -> None: """Handle save video service calls.""" camera_name = call.data[CONF_NAME] video_path = call.data[CONF_FILENAME] if not hass.config.is_allowed_path(video_path): _LOGGER.error("Can't write %s, no access to path!", video_path) return - - def _write_video(name, file_path): - """Call video write.""" - all_cameras = hass.data[DOMAIN][entry.entry_id].cameras - if name in all_cameras: - all_cameras[name].video_to_file(file_path) - try: - await hass.async_add_executor_job(_write_video, camera_name, video_path) + all_cameras = hass.data[DOMAIN][entry.entry_id].cameras + if camera_name in all_cameras: + await all_cameras[camera_name].video_to_file(video_path) + except OSError as err: _LOGGER.error("Can't write image to file: %s", err) -async def async_handle_save_recent_clips_service(hass, entry, call): +async def async_handle_save_recent_clips_service( + hass: HomeAssistant, entry: ConfigEntry, call +) -> None: """Save multiple recent clips to output directory.""" camera_name = call.data[CONF_NAME] clips_dir = call.data[CONF_FILE_PATH] @@ -204,13 +204,9 @@ async def async_handle_save_recent_clips_service(hass, entry, call): _LOGGER.error("Can't write to directory %s, no access to path!", clips_dir) return - def _save_recent_clips(name, output_dir): - """Call save recent clips.""" - all_cameras = hass.data[DOMAIN][entry.entry_id].cameras - if name in all_cameras: - all_cameras[name].save_recent_clips(output_dir=output_dir) - try: - await hass.async_add_executor_job(_save_recent_clips, camera_name, clips_dir) + all_cameras = hass.data[DOMAIN][entry.entry_id].cameras + if camera_name in all_cameras: + 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) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index 16a8c00d67a..b69f9b84670 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -1,8 +1,11 @@ """Support for Blink Alarm Control Panel.""" from __future__ import annotations +import asyncio import logging +from blinkpy.blinkpy import Blink + from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, @@ -16,6 +19,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN @@ -32,11 +36,11 @@ async def async_setup_entry( sync_modules = [] for sync_name, sync_module in data.sync.items(): - sync_modules.append(BlinkSyncModule(data, sync_name, sync_module)) - async_add_entities(sync_modules) + sync_modules.append(BlinkSyncModuleHA(data, sync_name, sync_module)) + async_add_entities(sync_modules, update_before_add=True) -class BlinkSyncModule(AlarmControlPanelEntity): +class BlinkSyncModuleHA(AlarmControlPanelEntity): """Representation of a Blink Alarm Control Panel.""" _attr_icon = ICON @@ -44,19 +48,19 @@ class BlinkSyncModule(AlarmControlPanelEntity): _attr_name = None _attr_has_entity_name = True - def __init__(self, data, name, sync): + def __init__(self, data, name: str, sync) -> None: """Initialize the alarm control panel.""" - self.data = data + self.data: Blink = data self.sync = sync - self._name = name - self._attr_unique_id = sync.serial + self._name: str = name + self._attr_unique_id: str = sync.serial self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, sync.serial)}, name=f"{DOMAIN} {name}", manufacturer=DEFAULT_BRAND, ) - def update(self) -> None: + async def async_update(self) -> None: """Update the state of the device.""" if self.data.check_if_ok_to_update(): _LOGGER.debug( @@ -64,23 +68,38 @@ class BlinkSyncModule(AlarmControlPanelEntity): self._name, self.data, ) - self.data.refresh() + try: + await self.data.refresh(force=True) + self._attr_available = True + except asyncio.TimeoutError: + self._attr_available = False + _LOGGER.info("Updating State of Blink Alarm Control Panel '%s'", self._name) - self._attr_state = ( - STATE_ALARM_ARMED_AWAY if self.sync.arm else STATE_ALARM_DISARMED - ) self.sync.attributes["network_info"] = self.data.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 - def alarm_disarm(self, code: str | None = None) -> None: - """Send disarm command.""" - self.sync.arm = False - self.sync.refresh() + @property + def state(self) -> StateType: + """Return state of alarm.""" + return STATE_ALARM_ARMED_AWAY if self.sync.arm else STATE_ALARM_DISARMED - def alarm_arm_away(self, code: str | None = None) -> None: + 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) + self.async_write_ha_state() + except asyncio.TimeoutError: + self._attr_available = False + + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm command.""" - self.sync.arm = True - self.sync.refresh() + try: + await self.sync.async_arm(True) + await self.sync.refresh(force=True) + self.async_write_ha_state() + except asyncio.TimeoutError: + self._attr_available = False diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 1b53a11b1d2..1edb8b91336 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -52,7 +52,7 @@ async def async_setup_entry( for camera in data.cameras for description in BINARY_SENSORS_TYPES ] - async_add_entities(entities) + async_add_entities(entities, update_before_add=True) class BlinkBinarySensor(BinarySensorEntity): @@ -75,15 +75,16 @@ class BlinkBinarySensor(BinarySensorEntity): model=self._camera.camera_type, ) - def update(self) -> None: + @property + def is_on(self) -> bool | None: """Update sensor state.""" - state = self._camera.attributes[self.entity_description.key] + is_on = self._camera.attributes[self.entity_description.key] _LOGGER.debug( "'%s' %s = %s", self._camera.attributes["name"], self.entity_description.key, - state, + is_on, ) if self.entity_description.key == TYPE_BATTERY: - state = state != "ok" - self._attr_is_on = state + is_on = is_on != "ok" + return is_on diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 9f9396c3888..1a28d52356e 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -1,7 +1,10 @@ """Support for Blink system camera.""" from __future__ import annotations +import asyncio +from collections.abc import Mapping import logging +from typing import Any from requests.exceptions import ChunkedEncodingError @@ -29,7 +32,7 @@ async def async_setup_entry( BlinkCamera(data, name, camera) for name, camera in data.cameras.items() ] - async_add_entities(entities) + async_add_entities(entities, update_before_add=True) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service(SERVICE_TRIGGER, {}, "trigger_camera") @@ -56,19 +59,25 @@ class BlinkCamera(Camera): _LOGGER.debug("Initialized blink camera %s", self.name) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the camera attributes.""" return self._camera.attributes - def enable_motion_detection(self) -> None: + async def async_enable_motion_detection(self) -> None: """Enable motion detection for the camera.""" - self._camera.arm = True - self.data.refresh() + try: + await self._camera.async_arm(True) + await self.data.refresh(force=True) + except asyncio.TimeoutError: + self._attr_available = False - def disable_motion_detection(self) -> None: + async def async_disable_motion_detection(self) -> None: """Disable motion detection for the camera.""" - self._camera.arm = False - self.data.refresh() + try: + await self._camera.async_arm(False) + await self.data.refresh(force=True) + except asyncio.TimeoutError: + self._attr_available = False @property def motion_detection_enabled(self) -> bool: @@ -76,21 +85,24 @@ class BlinkCamera(Camera): return self._camera.arm @property - def brand(self): + def brand(self) -> str | None: """Return the camera brand.""" return DEFAULT_BRAND - def trigger_camera(self): + async def trigger_camera(self) -> None: """Trigger camera to take a snapshot.""" - self._camera.snap_picture() - self.data.refresh() + try: + await self._camera.snap_picture() + self.async_schedule_update_ha_state(force_refresh=True) + except asyncio.TimeoutError: + pass def camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" try: - return self._camera.image_from_cache.content + return self._camera.image_from_cache except ChunkedEncodingError: _LOGGER.debug("Could not retrieve image for %s", self._camera.name) return None diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index d3b2878b522..cc740d8be31 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -16,10 +16,11 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_USERNAME, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, SchemaOptionsFlowHandler, @@ -49,23 +50,23 @@ OPTIONS_FLOW = { } -def validate_input(auth: Auth) -> None: +async def validate_input(auth: Auth) -> None: """Validate the user input allows us to connect.""" try: - auth.startup() + await auth.startup() except (LoginError, TokenRefreshFailed) as err: raise InvalidAuth from err if auth.check_key_required(): raise Require2FA -def _send_blink_2fa_pin(auth: Auth, pin: str | None) -> bool: +async def _send_blink_2fa_pin(hass: HomeAssistant, auth: Auth, pin: str) -> bool: """Send 2FA pin to blink servers.""" - blink = Blink() + blink = Blink(session=async_get_clientsession(hass)) blink.auth = auth blink.setup_login_ids() blink.setup_urls() - return auth.send_auth_key(blink, pin) + return await auth.send_auth_key(blink, pin) class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): @@ -91,11 +92,15 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a flow initiated by the user.""" errors = {} if user_input is not None: - self.auth = Auth({**user_input, "device_id": DEVICE_ID}, no_prompt=True) + self.auth = Auth( + {**user_input, "device_id": DEVICE_ID}, + no_prompt=True, + session=async_get_clientsession(self.hass), + ) await self.async_set_unique_id(user_input[CONF_USERNAME]) try: - await self.hass.async_add_executor_job(validate_input, self.auth) + await validate_input(self.auth) return self._async_finish_flow() except Require2FA: return await self.async_step_2fa() @@ -122,12 +127,9 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle 2FA step.""" errors = {} if user_input is not None: - pin: str | None = user_input.get(CONF_PIN) + pin: str = str(user_input.get(CONF_PIN)) try: - assert self.auth - valid_token = await self.hass.async_add_executor_job( - _send_blink_2fa_pin, self.auth, pin - ) + valid_token = await _send_blink_2fa_pin(self.hass, self.auth, pin) except BlinkSetupError: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index 302a9f1e86a..54f36ec6e2e 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/blink", "iot_class": "cloud_polling", "loggers": ["blinkpy"], - "requirements": ["blinkpy==0.21.0"] + "requirements": ["blinkpy==0.22.2"] } diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index ceec74a9aa9..e4fdabc29d1 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -1,12 +1,15 @@ """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 ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -17,6 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import DEFAULT_BRAND, DOMAIN, TYPE_TEMPERATURE, TYPE_WIFI_STRENGTH @@ -28,6 +32,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_WIFI_STRENGTH, @@ -35,6 +40,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, ), ) @@ -50,7 +56,7 @@ async def async_setup_entry( for description in SENSOR_TYPES ] - async_add_entities(entities) + async_add_entities(entities, update_before_add=True) class BlinkSensor(SensorEntity): @@ -76,10 +82,11 @@ class BlinkSensor(SensorEntity): model=self._camera.camera_type, ) - def update(self) -> None: + @property + def native_value(self) -> StateType | date | datetime | Decimal: """Retrieve sensor data from the camera.""" try: - self._attr_native_value = self._camera.attributes[self._sensor_key] + native_value = self._camera.attributes[self._sensor_key] _LOGGER.debug( "'%s' %s = %s", self._camera.attributes["name"], @@ -87,7 +94,8 @@ class BlinkSensor(SensorEntity): self._attr_native_value, ) except KeyError: - self._attr_native_value = None + native_value = None _LOGGER.error( "%s not a valid camera attribute. Did the API change?", self._sensor_key ) + return native_value diff --git a/requirements_all.txt b/requirements_all.txt index 764302218ca..1ae045131e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -539,7 +539,7 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.21.0 +blinkpy==0.22.2 # homeassistant.components.bitcoin blockchain==1.4.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 58e722c4064..20a6d47cbcb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -460,7 +460,7 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.21.0 +blinkpy==0.22.2 # homeassistant.components.bluemaestro bluemaestro-ble==0.2.3 diff --git a/tests/components/blink/test_config_flow.py b/tests/components/blink/test_config_flow.py index 8b1e13aaa70..0809a674600 100644 --- a/tests/components/blink/test_config_flow.py +++ b/tests/components/blink/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Blink config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from blinkpy.auth import LoginError from blinkpy.blinkpy import BlinkSetupError @@ -268,10 +268,10 @@ async def test_options_flow(hass: HomeAssistant) -> None: ) config_entry.add_to_hass(hass) - mock_auth = Mock( + mock_auth = AsyncMock( startup=Mock(return_value=True), check_key_required=Mock(return_value=False) ) - mock_blink = Mock() + mock_blink = AsyncMock() with patch("homeassistant.components.blink.Auth", return_value=mock_auth), patch( "homeassistant.components.blink.Blink", return_value=mock_blink @@ -293,7 +293,6 @@ async def test_options_flow(hass: HomeAssistant) -> None: result["flow_id"], user_input={"scan_interval": 5}, ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["data"] == {"scan_interval": 5} await hass.async_block_till_done()