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
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,
(

View File

@ -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
)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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.",

View File

@ -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

View File

@ -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

View File

@ -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",