Skip to content

Commit

Permalink
Convert doorbird to use asyncio (home-assistant#121569)
Browse files Browse the repository at this point in the history
  • Loading branch information
bdraco authored Jul 10, 2024
1 parent 020961d commit 0e0a339
Show file tree
Hide file tree
Showing 9 changed files with 67 additions and 72 deletions.
23 changes: 10 additions & 13 deletions homeassistant/components/doorbird/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@

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 (
Expand All @@ -19,6 +18,7 @@
)
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

Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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)
Expand All @@ -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,
(
Expand Down
11 changes: 7 additions & 4 deletions homeassistant/components/doorbird/button.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -19,7 +20,7 @@
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(
Expand Down Expand Up @@ -73,6 +74,8 @@ def __init__(
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
)
10 changes: 3 additions & 7 deletions homeassistant/components/doorbird/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@

from __future__ import annotations

import asyncio
import datetime
import logging

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

Expand Down Expand Up @@ -95,11 +93,9 @@ async def async_camera_image(
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
Expand Down
29 changes: 15 additions & 14 deletions homeassistant/components/doorbird/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
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
Expand All @@ -20,6 +20,7 @@
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
Expand All @@ -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:
Expand All @@ -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
Expand Down
34 changes: 14 additions & 20 deletions homeassistant/components/doorbird/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def token(self) -> str:
"""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:
Expand All @@ -88,14 +88,14 @@ def register_events(self, hass: HomeAssistant) -> None:
# 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
Expand All @@ -122,18 +122,18 @@ def slug(self) -> str:
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,
Expand All @@ -142,20 +142,20 @@ def _register_event(
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:
Expand All @@ -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)
2 changes: 1 addition & 1 deletion homeassistant/components/doorbird/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 15 additions & 11 deletions tests/components/doorbird/test_config_flow.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -25,18 +25,20 @@

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

Expand Down Expand Up @@ -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,
],
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down

0 comments on commit 0e0a339

Please sign in to comment.