From 0e0a3395171df16d86982ca02db1ba437db5146b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Jul 2024 08:34:12 -0700 Subject: [PATCH] Convert doorbird to use asyncio (#121569) --- homeassistant/components/doorbird/__init__.py | 23 ++++++------- homeassistant/components/doorbird/button.py | 11 +++--- homeassistant/components/doorbird/camera.py | 10 ++---- .../components/doorbird/config_flow.py | 29 ++++++++-------- homeassistant/components/doorbird/device.py | 34 ++++++++----------- .../components/doorbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/doorbird/test_config_flow.py | 26 ++++++++------ 9 files changed, 67 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index d232aa36cdb..32b3e31fb36 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -4,10 +4,9 @@ from __future__ import annotations from http import HTTPStatus import logging -from typing import Any +from aiohttp import ClientResponseError from doorbirdpy import DoorBird -import requests from homeassistant.components import persistent_notification from homeassistant.const import ( @@ -19,6 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -48,12 +48,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: DoorBirdConfigEntry) -> device_ip = door_station_config[CONF_HOST] username = door_station_config[CONF_USERNAME] password = door_station_config[CONF_PASSWORD] + session = async_get_clientsession(hass) - device = DoorBird(device_ip, username, password) + device = DoorBird(device_ip, username, password, http_session=session) try: - status, info = await hass.async_add_executor_job(_init_door_bird_device, device) - except requests.exceptions.HTTPError as err: - if err.response.status_code == HTTPStatus.UNAUTHORIZED: + status = await device.ready() + info = await device.info() + except ClientResponseError as err: + if err.status == HTTPStatus.UNAUTHORIZED: _LOGGER.error( "Authorization rejected by DoorBird for %s@%s", username, device_ip ) @@ -91,11 +93,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: DoorBirdConfigEntry) -> return True -def _init_door_bird_device(device: DoorBird) -> tuple[tuple[bool, int], dict[str, Any]]: - """Verify we can connect to the device and return the status.""" - return device.ready(), device.info() - - async def async_unload_entry(hass: HomeAssistant, entry: DoorBirdConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -106,8 +103,8 @@ async def _async_register_events( ) -> bool: """Register events on device.""" try: - await hass.async_add_executor_job(door_station.register_events, hass) - except requests.exceptions.HTTPError: + await door_station.async_register_events(hass) + except ClientResponseError: persistent_notification.async_create( hass, ( diff --git a/homeassistant/components/doorbird/button.py b/homeassistant/components/doorbird/button.py index b83ff966174..3580261b0a5 100644 --- a/homeassistant/components/doorbird/button.py +++ b/homeassistant/components/doorbird/button.py @@ -1,7 +1,8 @@ """Support for powering relays in a DoorBird video doorbell.""" -from collections.abc import Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass +from typing import Any from doorbirdpy import DoorBird @@ -19,7 +20,7 @@ IR_RELAY = "__ir_light__" class DoorbirdButtonEntityDescription(ButtonEntityDescription): """Class to describe a Doorbird Button entity.""" - press_action: Callable[[DoorBird, str], None] + press_action: Callable[[DoorBird, str], Coroutine[Any, Any, bool]] RELAY_ENTITY_DESCRIPTION = DoorbirdButtonEntityDescription( @@ -73,6 +74,8 @@ class DoorBirdButton(DoorBirdEntity, ButtonEntity): self._attr_name = f"Relay {self._relay}" self._attr_unique_id = f"{self._mac_addr}_{self._relay}" - def press(self) -> None: + async def async_press(self) -> None: """Power the relay.""" - self.entity_description.press_action(self._door_station.device, self._relay) + await self.entity_description.press_action( + self._door_station.device, self._relay + ) diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index 8ab7f748f4a..640d6630c18 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import datetime import logging @@ -10,7 +9,6 @@ import aiohttp from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util @@ -95,11 +93,9 @@ class DoorBirdCamera(DoorBirdEntity, Camera): return self._last_image try: - websession = async_get_clientsession(self.hass) - async with asyncio.timeout(_TIMEOUT): - response = await websession.get(self._url) - - self._last_image = await response.read() + self._last_image = await self._door_station.device.get_image( + self._url, timeout=_TIMEOUT + ) except TimeoutError: _LOGGER.error("DoorBird %s: Camera image timed out", self.name) return self._last_image diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index b59c03ac565..13e7d151d2f 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -6,8 +6,8 @@ from http import HTTPStatus import logging from typing import Any +from aiohttp import ClientResponseError from doorbirdpy import DoorBird -import requests import voluptuous as vol from homeassistant.components import zeroconf @@ -20,6 +20,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_EVENTS, DOMAIN, DOORBIRD_OUI from .util import get_mac_address_from_door_station_info @@ -40,18 +41,17 @@ def _schema_with_defaults( ) -def _check_device(device: DoorBird) -> tuple[tuple[bool, int], dict[str, Any]]: - """Verify we can connect to the device and return the status.""" - return device.ready(), device.info() - - async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: """Validate the user input allows us to connect.""" - device = DoorBird(data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD]) + session = async_get_clientsession(hass) + device = DoorBird( + data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD], http_session=session + ) try: - status, info = await hass.async_add_executor_job(_check_device, device) - except requests.exceptions.HTTPError as err: - if err.response.status_code == HTTPStatus.UNAUTHORIZED: + status = await device.ready() + info = await device.info() + except ClientResponseError as err: + if err.status == HTTPStatus.UNAUTHORIZED: raise InvalidAuth from err raise CannotConnect from err except OSError as err: @@ -68,11 +68,12 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, async def async_verify_supported_device(hass: HomeAssistant, host: str) -> bool: """Verify the doorbell state endpoint returns a 401.""" - device = DoorBird(host, "", "") + session = async_get_clientsession(hass) + device = DoorBird(host, "", "", http_session=session) try: - await hass.async_add_executor_job(device.doorbell_state) - except requests.exceptions.HTTPError as err: - if err.response.status_code == HTTPStatus.UNAUTHORIZED: + await device.doorbell_state() + except ClientResponseError as err: + if err.status == HTTPStatus.UNAUTHORIZED: return True except OSError: return False diff --git a/homeassistant/components/doorbird/device.py b/homeassistant/components/doorbird/device.py index f1ede43bbd4..a7afea02caa 100644 --- a/homeassistant/components/doorbird/device.py +++ b/homeassistant/components/doorbird/device.py @@ -75,7 +75,7 @@ class ConfiguredDoorBird: """Get token for device.""" return self._token - def register_events(self, hass: HomeAssistant) -> None: + async def async_register_events(self, hass: HomeAssistant) -> None: """Register events on device.""" # Override url if another is specified in the configuration if custom_url := self.custom_url: @@ -88,14 +88,14 @@ class ConfiguredDoorBird: # User may not have permission to get the favorites return - favorites = self.device.favorites() + favorites = await self.device.favorites() for event in self.door_station_events: - if self._register_event(hass_url, event, favs=favorites): + if await self._async_register_event(hass_url, event, favs=favorites): _LOGGER.info( "Successfully registered URL for %s on %s", event, self.name ) - schedule: list[DoorBirdScheduleEntry] = self.device.schedule() + schedule: list[DoorBirdScheduleEntry] = await self.device.schedule() http_fav: dict[str, dict[str, Any]] = favorites.get("http") or {} favorite_input_type: dict[str, str] = { output.param: entry.input @@ -122,18 +122,18 @@ class ConfiguredDoorBird: def _get_event_name(self, event: str) -> str: return f"{self.slug}_{event}" - def _register_event( + async def _async_register_event( self, hass_url: str, event: str, favs: dict[str, Any] | None = None ) -> bool: """Add a schedule entry in the device for a sensor.""" url = f"{hass_url}{API_URL}/{event}?token={self._token}" # Register HA URL as webhook if not already, then get the ID - if self.webhook_is_registered(url, favs=favs): + if await self.async_webhook_is_registered(url, favs=favs): return True - self.device.change_favorite("http", f"Home Assistant ({event})", url) - if not self.webhook_is_registered(url): + await self.device.change_favorite("http", f"Home Assistant ({event})", url) + if not await self.async_webhook_is_registered(url): _LOGGER.warning( 'Unable to set favorite URL "%s". Event "%s" will not fire', url, @@ -142,20 +142,20 @@ class ConfiguredDoorBird: return False return True - def webhook_is_registered( + async def async_webhook_is_registered( self, url: str, favs: dict[str, Any] | None = None ) -> bool: """Return whether the given URL is registered as a device favorite.""" - return self.get_webhook_id(url, favs) is not None + return await self.async_get_webhook_id(url, favs) is not None - def get_webhook_id( + async def async_get_webhook_id( self, url: str, favs: dict[str, Any] | None = None ) -> str | None: """Return the device favorite ID for the given URL. The favorite must exist or there will be problems. """ - favs = favs if favs else self.device.favorites() + favs = favs if favs else await self.device.favorites() http_fav: dict[str, dict[str, Any]] = favs.get("http") or {} for fav_id, data in http_fav.items(): if data["value"] == url: @@ -178,14 +178,8 @@ async def async_reset_device_favorites( hass: HomeAssistant, door_station: ConfiguredDoorBird ) -> None: """Handle clearing favorites on device.""" - await hass.async_add_executor_job(_reset_device_favorites, door_station) - - -def _reset_device_favorites(door_station: ConfiguredDoorBird) -> None: - """Handle clearing favorites on device.""" - # Clear webhooks door_bird = door_station.device - favorites: dict[str, list[str]] = door_bird.favorites() + favorites: dict[str, dict[str, Any]] = await door_bird.favorites() for favorite_type, favorite_ids in favorites.items(): for favorite_id in favorite_ids: - door_bird.delete_favorite(favorite_type, favorite_id) + await door_bird.delete_favorite(favorite_type, favorite_id) diff --git a/homeassistant/components/doorbird/manifest.json b/homeassistant/components/doorbird/manifest.json index 2bb981ab06f..0d0d0abc8b6 100644 --- a/homeassistant/components/doorbird/manifest.json +++ b/homeassistant/components/doorbird/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/doorbird", "iot_class": "local_push", "loggers": ["doorbirdpy"], - "requirements": ["DoorBirdPy==2.1.0"], + "requirements": ["DoorBirdPy==3.0.0"], "zeroconf": [ { "type": "_axis-video._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 6821a8b2869..7aa6b9d002b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -16,7 +16,7 @@ Adax-local==0.1.5 BlinkStick==1.2.0 # homeassistant.components.doorbird -DoorBirdPy==2.1.0 +DoorBirdPy==3.0.0 # homeassistant.components.homekit HAP-python==4.9.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8d68a14551b..f1d5dd07b5c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -13,7 +13,7 @@ AIOSomecomfort==0.0.25 Adax-local==0.1.5 # homeassistant.components.doorbird -DoorBirdPy==2.1.0 +DoorBirdPy==3.0.0 # homeassistant.components.homekit HAP-python==4.9.1 diff --git a/tests/components/doorbird/test_config_flow.py b/tests/components/doorbird/test_config_flow.py index cd4ddccda87..d77c5a81d96 100644 --- a/tests/components/doorbird/test_config_flow.py +++ b/tests/components/doorbird/test_config_flow.py @@ -1,10 +1,10 @@ """Test the DoorBird config flow.""" from ipaddress import ip_address -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch +import aiohttp import pytest -import requests from homeassistant import config_entries from homeassistant.components import zeroconf @@ -25,18 +25,20 @@ VALID_CONFIG = { def _get_mock_doorbirdapi_return_values(ready=None, info=None): doorbirdapi_mock = MagicMock() - type(doorbirdapi_mock).ready = MagicMock(return_value=ready) - type(doorbirdapi_mock).info = MagicMock(return_value=info) - type(doorbirdapi_mock).doorbell_state = MagicMock( - side_effect=requests.exceptions.HTTPError(response=Mock(status_code=401)) + type(doorbirdapi_mock).ready = AsyncMock(return_value=ready) + type(doorbirdapi_mock).info = AsyncMock(return_value=info) + type(doorbirdapi_mock).doorbell_state = AsyncMock( + side_effect=aiohttp.ClientResponseError( + request_info=Mock(), history=Mock(), status=401 + ) ) return doorbirdapi_mock def _get_mock_doorbirdapi_side_effects(ready=None, info=None): doorbirdapi_mock = MagicMock() - type(doorbirdapi_mock).ready = MagicMock(side_effect=ready) - type(doorbirdapi_mock).info = MagicMock(side_effect=info) + type(doorbirdapi_mock).ready = AsyncMock(side_effect=ready) + type(doorbirdapi_mock).info = AsyncMock(side_effect=info) return doorbirdapi_mock @@ -234,7 +236,7 @@ async def test_form_zeroconf_correct_oui(hass: HomeAssistant) -> None: @pytest.mark.parametrize( "doorbell_state_side_effect", [ - requests.exceptions.HTTPError(response=Mock(status_code=404)), + aiohttp.ClientResponseError(request_info=Mock(), history=Mock(), status=404), OSError, None, ], @@ -246,7 +248,7 @@ async def test_form_zeroconf_correct_oui_wrong_device( doorbirdapi = _get_mock_doorbirdapi_return_values( ready=[True], info={"WIFI_MAC_ADDR": "macaddr"} ) - type(doorbirdapi).doorbell_state = MagicMock(side_effect=doorbell_state_side_effect) + type(doorbirdapi).doorbell_state = AsyncMock(side_effect=doorbell_state_side_effect) with patch( "homeassistant.components.doorbird.config_flow.DoorBird", @@ -296,7 +298,9 @@ async def test_form_user_invalid_auth(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_error = requests.exceptions.HTTPError(response=Mock(status_code=401)) + mock_error = aiohttp.ClientResponseError( + request_info=Mock(), history=Mock(), status=401 + ) doorbirdapi = _get_mock_doorbirdapi_side_effects(ready=mock_error) with patch( "homeassistant.components.doorbird.config_flow.DoorBird",