Convert doorbird to use asyncio (#121569)

This commit is contained in:
J. Nick Koston 2024-07-10 08:34:12 -07:00 committed by GitHub
parent 020961d2d8
commit 0e0a339517
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 67 additions and 72 deletions

View File

@ -4,10 +4,9 @@ from __future__ import annotations
from http import HTTPStatus from http import HTTPStatus
import logging import logging
from typing import Any
from aiohttp import ClientResponseError
from doorbirdpy import DoorBird from doorbirdpy import DoorBird
import requests
from homeassistant.components import persistent_notification from homeassistant.components import persistent_notification
from homeassistant.const import ( from homeassistant.const import (
@ -19,6 +18,7 @@ from homeassistant.const import (
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType 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] device_ip = door_station_config[CONF_HOST]
username = door_station_config[CONF_USERNAME] username = door_station_config[CONF_USERNAME]
password = door_station_config[CONF_PASSWORD] 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: try:
status, info = await hass.async_add_executor_job(_init_door_bird_device, device) status = await device.ready()
except requests.exceptions.HTTPError as err: info = await device.info()
if err.response.status_code == HTTPStatus.UNAUTHORIZED: except ClientResponseError as err:
if err.status == HTTPStatus.UNAUTHORIZED:
_LOGGER.error( _LOGGER.error(
"Authorization rejected by DoorBird for %s@%s", username, device_ip "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 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: async def async_unload_entry(hass: HomeAssistant, entry: DoorBirdConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@ -106,8 +103,8 @@ async def _async_register_events(
) -> bool: ) -> bool:
"""Register events on device.""" """Register events on device."""
try: try:
await hass.async_add_executor_job(door_station.register_events, hass) await door_station.async_register_events(hass)
except requests.exceptions.HTTPError: except ClientResponseError:
persistent_notification.async_create( persistent_notification.async_create(
hass, hass,
( (

View File

@ -1,7 +1,8 @@
"""Support for powering relays in a DoorBird video doorbell.""" """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 dataclasses import dataclass
from typing import Any
from doorbirdpy import DoorBird from doorbirdpy import DoorBird
@ -19,7 +20,7 @@ IR_RELAY = "__ir_light__"
class DoorbirdButtonEntityDescription(ButtonEntityDescription): class DoorbirdButtonEntityDescription(ButtonEntityDescription):
"""Class to describe a Doorbird Button entity.""" """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( RELAY_ENTITY_DESCRIPTION = DoorbirdButtonEntityDescription(
@ -73,6 +74,8 @@ class DoorBirdButton(DoorBirdEntity, ButtonEntity):
self._attr_name = f"Relay {self._relay}" self._attr_name = f"Relay {self._relay}"
self._attr_unique_id = f"{self._mac_addr}_{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.""" """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
)

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import datetime import datetime
import logging import logging
@ -10,7 +9,6 @@ import aiohttp
from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
@ -95,11 +93,9 @@ class DoorBirdCamera(DoorBirdEntity, Camera):
return self._last_image return self._last_image
try: try:
websession = async_get_clientsession(self.hass) self._last_image = await self._door_station.device.get_image(
async with asyncio.timeout(_TIMEOUT): self._url, timeout=_TIMEOUT
response = await websession.get(self._url) )
self._last_image = await response.read()
except TimeoutError: except TimeoutError:
_LOGGER.error("DoorBird %s: Camera image timed out", self.name) _LOGGER.error("DoorBird %s: Camera image timed out", self.name)
return self._last_image return self._last_image

View File

@ -6,8 +6,8 @@ from http import HTTPStatus
import logging import logging
from typing import Any from typing import Any
from aiohttp import ClientResponseError
from doorbirdpy import DoorBird from doorbirdpy import DoorBird
import requests
import voluptuous as vol import voluptuous as vol
from homeassistant.components import zeroconf 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.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_EVENTS, DOMAIN, DOORBIRD_OUI from .const import CONF_EVENTS, DOMAIN, DOORBIRD_OUI
from .util import get_mac_address_from_door_station_info 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]: async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
"""Validate the user input allows us to connect.""" """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: try:
status, info = await hass.async_add_executor_job(_check_device, device) status = await device.ready()
except requests.exceptions.HTTPError as err: info = await device.info()
if err.response.status_code == HTTPStatus.UNAUTHORIZED: except ClientResponseError as err:
if err.status == HTTPStatus.UNAUTHORIZED:
raise InvalidAuth from err raise InvalidAuth from err
raise CannotConnect from err raise CannotConnect from err
except OSError as 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: async def async_verify_supported_device(hass: HomeAssistant, host: str) -> bool:
"""Verify the doorbell state endpoint returns a 401.""" """Verify the doorbell state endpoint returns a 401."""
device = DoorBird(host, "", "") session = async_get_clientsession(hass)
device = DoorBird(host, "", "", http_session=session)
try: try:
await hass.async_add_executor_job(device.doorbell_state) await device.doorbell_state()
except requests.exceptions.HTTPError as err: except ClientResponseError as err:
if err.response.status_code == HTTPStatus.UNAUTHORIZED: if err.status == HTTPStatus.UNAUTHORIZED:
return True return True
except OSError: except OSError:
return False return False

View File

@ -75,7 +75,7 @@ class ConfiguredDoorBird:
"""Get token for device.""" """Get token for device."""
return self._token return self._token
def register_events(self, hass: HomeAssistant) -> None: async def async_register_events(self, hass: HomeAssistant) -> None:
"""Register events on device.""" """Register events on device."""
# Override url if another is specified in the configuration # Override url if another is specified in the configuration
if custom_url := self.custom_url: if custom_url := self.custom_url:
@ -88,14 +88,14 @@ class ConfiguredDoorBird:
# User may not have permission to get the favorites # User may not have permission to get the favorites
return return
favorites = self.device.favorites() favorites = await self.device.favorites()
for event in self.door_station_events: 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( _LOGGER.info(
"Successfully registered URL for %s on %s", event, self.name "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 {} http_fav: dict[str, dict[str, Any]] = favorites.get("http") or {}
favorite_input_type: dict[str, str] = { favorite_input_type: dict[str, str] = {
output.param: entry.input output.param: entry.input
@ -122,18 +122,18 @@ class ConfiguredDoorBird:
def _get_event_name(self, event: str) -> str: def _get_event_name(self, event: str) -> str:
return f"{self.slug}_{event}" 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 self, hass_url: str, event: str, favs: dict[str, Any] | None = None
) -> bool: ) -> bool:
"""Add a schedule entry in the device for a sensor.""" """Add a schedule entry in the device for a sensor."""
url = f"{hass_url}{API_URL}/{event}?token={self._token}" url = f"{hass_url}{API_URL}/{event}?token={self._token}"
# Register HA URL as webhook if not already, then get the ID # 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 return True
self.device.change_favorite("http", f"Home Assistant ({event})", url) await self.device.change_favorite("http", f"Home Assistant ({event})", url)
if not self.webhook_is_registered(url): if not await self.async_webhook_is_registered(url):
_LOGGER.warning( _LOGGER.warning(
'Unable to set favorite URL "%s". Event "%s" will not fire', 'Unable to set favorite URL "%s". Event "%s" will not fire',
url, url,
@ -142,20 +142,20 @@ class ConfiguredDoorBird:
return False return False
return True return True
def webhook_is_registered( async def async_webhook_is_registered(
self, url: str, favs: dict[str, Any] | None = None self, url: str, favs: dict[str, Any] | None = None
) -> bool: ) -> bool:
"""Return whether the given URL is registered as a device favorite.""" """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 self, url: str, favs: dict[str, Any] | None = None
) -> str | None: ) -> str | None:
"""Return the device favorite ID for the given URL. """Return the device favorite ID for the given URL.
The favorite must exist or there will be problems. 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 {} http_fav: dict[str, dict[str, Any]] = favs.get("http") or {}
for fav_id, data in http_fav.items(): for fav_id, data in http_fav.items():
if data["value"] == url: if data["value"] == url:
@ -178,14 +178,8 @@ async def async_reset_device_favorites(
hass: HomeAssistant, door_station: ConfiguredDoorBird hass: HomeAssistant, door_station: ConfiguredDoorBird
) -> None: ) -> None:
"""Handle clearing favorites on device.""" """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 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_type, favorite_ids in favorites.items():
for favorite_id in favorite_ids: for favorite_id in favorite_ids:
door_bird.delete_favorite(favorite_type, favorite_id) await door_bird.delete_favorite(favorite_type, favorite_id)

View File

@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/doorbird", "documentation": "https://www.home-assistant.io/integrations/doorbird",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["doorbirdpy"], "loggers": ["doorbirdpy"],
"requirements": ["DoorBirdPy==2.1.0"], "requirements": ["DoorBirdPy==3.0.0"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_axis-video._tcp.local.", "type": "_axis-video._tcp.local.",

View File

@ -16,7 +16,7 @@ Adax-local==0.1.5
BlinkStick==1.2.0 BlinkStick==1.2.0
# homeassistant.components.doorbird # homeassistant.components.doorbird
DoorBirdPy==2.1.0 DoorBirdPy==3.0.0
# homeassistant.components.homekit # homeassistant.components.homekit
HAP-python==4.9.1 HAP-python==4.9.1

View File

@ -13,7 +13,7 @@ AIOSomecomfort==0.0.25
Adax-local==0.1.5 Adax-local==0.1.5
# homeassistant.components.doorbird # homeassistant.components.doorbird
DoorBirdPy==2.1.0 DoorBirdPy==3.0.0
# homeassistant.components.homekit # homeassistant.components.homekit
HAP-python==4.9.1 HAP-python==4.9.1

View File

@ -1,10 +1,10 @@
"""Test the DoorBird config flow.""" """Test the DoorBird config flow."""
from ipaddress import ip_address 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 pytest
import requests
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components import zeroconf from homeassistant.components import zeroconf
@ -25,18 +25,20 @@ VALID_CONFIG = {
def _get_mock_doorbirdapi_return_values(ready=None, info=None): def _get_mock_doorbirdapi_return_values(ready=None, info=None):
doorbirdapi_mock = MagicMock() doorbirdapi_mock = MagicMock()
type(doorbirdapi_mock).ready = MagicMock(return_value=ready) type(doorbirdapi_mock).ready = AsyncMock(return_value=ready)
type(doorbirdapi_mock).info = MagicMock(return_value=info) type(doorbirdapi_mock).info = AsyncMock(return_value=info)
type(doorbirdapi_mock).doorbell_state = MagicMock( type(doorbirdapi_mock).doorbell_state = AsyncMock(
side_effect=requests.exceptions.HTTPError(response=Mock(status_code=401)) side_effect=aiohttp.ClientResponseError(
request_info=Mock(), history=Mock(), status=401
)
) )
return doorbirdapi_mock return doorbirdapi_mock
def _get_mock_doorbirdapi_side_effects(ready=None, info=None): def _get_mock_doorbirdapi_side_effects(ready=None, info=None):
doorbirdapi_mock = MagicMock() doorbirdapi_mock = MagicMock()
type(doorbirdapi_mock).ready = MagicMock(side_effect=ready) type(doorbirdapi_mock).ready = AsyncMock(side_effect=ready)
type(doorbirdapi_mock).info = MagicMock(side_effect=info) type(doorbirdapi_mock).info = AsyncMock(side_effect=info)
return doorbirdapi_mock return doorbirdapi_mock
@ -234,7 +236,7 @@ async def test_form_zeroconf_correct_oui(hass: HomeAssistant) -> None:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"doorbell_state_side_effect", "doorbell_state_side_effect",
[ [
requests.exceptions.HTTPError(response=Mock(status_code=404)), aiohttp.ClientResponseError(request_info=Mock(), history=Mock(), status=404),
OSError, OSError,
None, None,
], ],
@ -246,7 +248,7 @@ async def test_form_zeroconf_correct_oui_wrong_device(
doorbirdapi = _get_mock_doorbirdapi_return_values( doorbirdapi = _get_mock_doorbirdapi_return_values(
ready=[True], info={"WIFI_MAC_ADDR": "macaddr"} 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( with patch(
"homeassistant.components.doorbird.config_flow.DoorBird", "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} 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) doorbirdapi = _get_mock_doorbirdapi_side_effects(ready=mock_error)
with patch( with patch(
"homeassistant.components.doorbird.config_flow.DoorBird", "homeassistant.components.doorbird.config_flow.DoorBird",