Config flow for doorbird (#33165)

* Config flow for doorbird

* Discoverable via zeroconf

* Fix zeroconf test

* add missing return

* Add a test for legacy over ride url (will go away when refactored to cloud hooks)

* Update homeassistant/components/doorbird/__init__.py

Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io>

* without getting the hooks its not so useful

* Update homeassistant/components/doorbird/config_flow.py

Co-Authored-By: Paulus Schoutsen <paulus@home-assistant.io>

* fix copy pasta

* remove identifiers since its in connections

* self review fixes

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
pull/33026/head
J. Nick Koston 2020-03-23 04:14:21 -05:00 committed by GitHub
parent 49ebea2be3
commit f9a7c64106
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 802 additions and 121 deletions

View File

@ -86,7 +86,7 @@ homeassistant/components/device_automation/* @home-assistant/core
homeassistant/components/digital_ocean/* @fabaff
homeassistant/components/directv/* @ctalkington
homeassistant/components/discogs/* @thibmaek
homeassistant/components/doorbird/* @oblogic7
homeassistant/components/doorbird/* @oblogic7 @bdraco
homeassistant/components/dsmr_reader/* @depl0y
homeassistant/components/dweet/* @fabaff
homeassistant/components/dynalite/* @ziv1234

View File

@ -0,0 +1,34 @@
{
"options" : {
"step" : {
"init" : {
"data" : {
"events" : "Comma separated list of events."
},
"description" : "Add an comma separated event name for each event you wish to track. After entering them here, use the DoorBird app to assign them to a specific event. See the documentation at https://www.home-assistant.io/integrations/doorbird/#events. Example: somebody_pressed_the_button, motion"
}
}
},
"config" : {
"step" : {
"user" : {
"title" : "Connect to the DoorBird",
"data" : {
"password" : "Password",
"host" : "Host (IP Address)",
"name" : "Device Name",
"username" : "Username"
}
}
},
"abort" : {
"already_configured" : "This DoorBird is already configured"
},
"title" : "DoorBird",
"error" : {
"invalid_auth" : "Invalid authentication",
"unknown" : "Unexpected error",
"cannot_connect" : "Failed to connect, please try again"
}
}
}

View File

@ -1,5 +1,7 @@
"""Support for DoorBird devices."""
import asyncio
import logging
import urllib
from urllib.error import HTTPError
from doorbirdpy import DoorBird
@ -7,6 +9,7 @@ import voluptuous as vol
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.logbook import log_entry
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_DEVICES,
CONF_HOST,
@ -15,17 +18,19 @@ from homeassistant.const import (
CONF_TOKEN,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
import homeassistant.helpers.config_validation as cv
from homeassistant.util import dt as dt_util, slugify
_LOGGER = logging.getLogger(__name__)
from .const import CONF_EVENTS, DOMAIN, DOOR_STATION, DOOR_STATION_INFO, PLATFORMS
from .util import get_doorstation_by_token
DOMAIN = "doorbird"
_LOGGER = logging.getLogger(__name__)
API_URL = f"/api/{DOMAIN}"
CONF_CUSTOM_URL = "hass_url_override"
CONF_EVENTS = "events"
RESET_DEVICE_FAVORITES = "doorbird_reset_favorites"
@ -51,72 +56,24 @@ CONFIG_SCHEMA = vol.Schema(
)
def setup(hass, config):
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the DoorBird component."""
hass.data.setdefault(DOMAIN, {})
# Provide an endpoint for the doorstations to call to trigger events
hass.http.register_view(DoorBirdRequestView)
doorstations = []
if DOMAIN in config and CONF_DEVICES in config[DOMAIN]:
for index, doorstation_config in enumerate(config[DOMAIN][CONF_DEVICES]):
if CONF_NAME not in doorstation_config:
doorstation_config[CONF_NAME] = f"DoorBird {index + 1}"
for index, doorstation_config in enumerate(config[DOMAIN][CONF_DEVICES]):
device_ip = doorstation_config.get(CONF_HOST)
username = doorstation_config.get(CONF_USERNAME)
password = doorstation_config.get(CONF_PASSWORD)
custom_url = doorstation_config.get(CONF_CUSTOM_URL)
events = doorstation_config.get(CONF_EVENTS)
token = doorstation_config.get(CONF_TOKEN)
name = doorstation_config.get(CONF_NAME) or f"DoorBird {index + 1}"
try:
device = DoorBird(device_ip, username, password)
status = device.ready()
except OSError as oserr:
_LOGGER.error(
"Failed to setup doorbird at %s: %s; not retrying", device_ip, oserr
)
continue
if status[0]:
doorstation = ConfiguredDoorBird(device, name, events, custom_url, token)
doorstations.append(doorstation)
_LOGGER.info(
'Connected to DoorBird "%s" as %s@%s',
doorstation.name,
username,
device_ip,
)
elif status[1] == 401:
_LOGGER.error(
"Authorization rejected by DoorBird for %s@%s", username, device_ip
)
return False
else:
_LOGGER.error(
"Could not connect to DoorBird as %s@%s: Error %s",
username,
device_ip,
str(status[1]),
)
return False
# Subscribe to doorbell or motion events
if events:
try:
doorstation.register_events(hass)
except HTTPError:
hass.components.persistent_notification.create(
"Doorbird configuration failed. Please verify that API "
"Operator permission is enabled for the Doorbird user. "
"A restart will be required once permissions have been "
"verified.",
title="Doorbird Configuration Failure",
notification_id="doorbird_schedule_error",
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=doorstation_config,
)
return False
hass.data[DOMAIN] = doorstations
)
def _reset_device_favorites_handler(event):
"""Handle clearing favorites on device."""
@ -129,6 +86,7 @@ def setup(hass, config):
if doorstation is None:
_LOGGER.error("Device not found for provided token.")
return
# Clear webhooks
favorites = doorstation.device.favorites()
@ -137,16 +95,126 @@ def setup(hass, config):
for favorite_id in favorites[favorite_type]:
doorstation.device.delete_favorite(favorite_type, favorite_id)
hass.bus.listen(RESET_DEVICE_FAVORITES, _reset_device_favorites_handler)
hass.bus.async_listen(RESET_DEVICE_FAVORITES, _reset_device_favorites_handler)
return True
def get_doorstation_by_token(hass, token):
"""Get doorstation by slug."""
for doorstation in hass.data[DOMAIN]:
if token == doorstation.token:
return doorstation
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up DoorBird from a config entry."""
_async_import_options_from_data_if_missing(hass, entry)
doorstation_config = entry.data
doorstation_options = entry.options
config_entry_id = entry.entry_id
device_ip = doorstation_config[CONF_HOST]
username = doorstation_config[CONF_USERNAME]
password = doorstation_config[CONF_PASSWORD]
device = DoorBird(device_ip, username, password)
try:
status = await hass.async_add_executor_job(device.ready)
info = await hass.async_add_executor_job(device.info)
except urllib.error.HTTPError as err:
if err.code == 401:
_LOGGER.error(
"Authorization rejected by DoorBird for %s@%s", username, device_ip
)
return False
raise ConfigEntryNotReady
except OSError as oserr:
_LOGGER.error("Failed to setup doorbird at %s: %s", device_ip, oserr)
raise ConfigEntryNotReady
if not status[0]:
_LOGGER.error(
"Could not connect to DoorBird as %s@%s: Error %s",
username,
device_ip,
str(status[1]),
)
raise ConfigEntryNotReady
token = doorstation_config.get(CONF_TOKEN, config_entry_id)
custom_url = doorstation_config.get(CONF_CUSTOM_URL)
name = doorstation_config.get(CONF_NAME)
events = doorstation_options.get(CONF_EVENTS, [])
doorstation = ConfiguredDoorBird(device, name, events, custom_url, token)
# Subscribe to doorbell or motion events
if not await _async_register_events(hass, doorstation):
raise ConfigEntryNotReady
hass.data[DOMAIN][config_entry_id] = {
DOOR_STATION: doorstation,
DOOR_STATION_INFO: info,
}
entry.add_update_listener(_update_listener)
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def _async_register_events(hass, doorstation):
try:
await hass.async_add_executor_job(doorstation.register_events, hass)
except HTTPError:
hass.components.persistent_notification.create(
"Doorbird configuration failed. Please verify that API "
"Operator permission is enabled for the Doorbird user. "
"A restart will be required once permissions have been "
"verified.",
title="Doorbird Configuration Failure",
notification_id="doorbird_schedule_error",
)
return False
return True
async def _update_listener(hass: HomeAssistant, entry: ConfigEntry):
"""Handle options update."""
config_entry_id = entry.entry_id
doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION]
doorstation.events = entry.options[CONF_EVENTS]
# Subscribe to doorbell or motion events
await _async_register_events(hass, doorstation)
@callback
def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry):
options = dict(entry.options)
modified = False
for importable_option in [CONF_EVENTS]:
if importable_option not in entry.options and importable_option in entry.data:
options[importable_option] = entry.data[importable_option]
modified = True
if modified:
hass.config_entries.async_update_entry(entry, options=options)
class ConfiguredDoorBird:
@ -157,7 +225,7 @@ class ConfiguredDoorBird:
self._name = name
self._device = device
self._custom_url = custom_url
self._events = events
self.events = events
self._token = token
@property
@ -189,7 +257,7 @@ class ConfiguredDoorBird:
if self.custom_url is not None:
hass_url = self.custom_url
for event in self._events:
for event in self.events:
event = self._get_event_name(event)
self._register_event(hass_url, event)

View File

@ -10,46 +10,69 @@ from homeassistant.components.camera import SUPPORT_STREAM, Camera
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.util.dt as dt_util
from . import DOMAIN as DOORBIRD_DOMAIN
from .const import DOMAIN, DOOR_STATION, DOOR_STATION_INFO
from .entity import DoorBirdEntity
_LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=1)
_LAST_MOTION_INTERVAL = datetime.timedelta(minutes=1)
_LIVE_INTERVAL = datetime.timedelta(seconds=1)
_LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=2)
_LAST_MOTION_INTERVAL = datetime.timedelta(seconds=30)
_LIVE_INTERVAL = datetime.timedelta(seconds=45)
_LOGGER = logging.getLogger(__name__)
_TIMEOUT = 10 # seconds
_TIMEOUT = 15 # seconds
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the DoorBird camera platform."""
for doorstation in hass.data[DOORBIRD_DOMAIN]:
device = doorstation.device
async_add_entities(
[
DoorBirdCamera(
device.live_image_url,
f"{doorstation.name} Live",
_LIVE_INTERVAL,
device.rtsp_live_video_url,
),
DoorBirdCamera(
device.history_image_url(1, "doorbell"),
f"{doorstation.name} Last Ring",
_LAST_VISITOR_INTERVAL,
),
DoorBirdCamera(
device.history_image_url(1, "motionsensor"),
f"{doorstation.name} Last Motion",
_LAST_MOTION_INTERVAL,
),
]
)
config_entry_id = config_entry.entry_id
doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION]
doorstation_info = hass.data[DOMAIN][config_entry_id][DOOR_STATION_INFO]
device = doorstation.device
async_add_entities(
[
DoorBirdCamera(
doorstation,
doorstation_info,
device.live_image_url,
"live",
f"{doorstation.name} Live",
_LIVE_INTERVAL,
device.rtsp_live_video_url,
),
DoorBirdCamera(
doorstation,
doorstation_info,
device.history_image_url(1, "doorbell"),
"last_ring",
f"{doorstation.name} Last Ring",
_LAST_VISITOR_INTERVAL,
),
DoorBirdCamera(
doorstation,
doorstation_info,
device.history_image_url(1, "motionsensor"),
"last_motion",
f"{doorstation.name} Last Motion",
_LAST_MOTION_INTERVAL,
),
]
)
class DoorBirdCamera(Camera):
class DoorBirdCamera(DoorBirdEntity, Camera):
"""The camera on a DoorBird device."""
def __init__(self, url, name, interval=None, stream_url=None):
def __init__(
self,
doorstation,
doorstation_info,
url,
camera_id,
name,
interval=None,
stream_url=None,
):
"""Initialize the camera on a DoorBird device."""
super().__init__(doorstation, doorstation_info)
self._url = url
self._stream_url = stream_url
self._name = name
@ -57,12 +80,17 @@ class DoorBirdCamera(Camera):
self._supported_features = SUPPORT_STREAM if self._stream_url else 0
self._interval = interval or datetime.timedelta
self._last_update = datetime.datetime.min
super().__init__()
self._unique_id = f"{self._mac_addr}_{camera_id}"
async def stream_source(self):
"""Return the stream source."""
return self._stream_url
@property
def unique_id(self):
"""Camera Unique id."""
return self._unique_id
@property
def supported_features(self):
"""Return supported features."""
@ -89,8 +117,10 @@ class DoorBirdCamera(Camera):
self._last_update = now
return self._last_image
except asyncio.TimeoutError:
_LOGGER.error("Camera image timed out")
_LOGGER.error("DoorBird %s: Camera image timed out", self._name)
return self._last_image
except aiohttp.ClientError as error:
_LOGGER.error("Error getting camera image: %s", error)
_LOGGER.error(
"DoorBird %s: Error getting camera image: %s", self._name, error
)
return self._last_image

View File

@ -0,0 +1,156 @@
"""Config flow for DoorBird integration."""
import logging
import urllib
from doorbirdpy import DoorBird
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from .const import CONF_EVENTS, DOORBIRD_OUI
from .const import DOMAIN # pylint:disable=unused-import
from .util import get_mac_address_from_doorstation_info
_LOGGER = logging.getLogger(__name__)
def _schema_with_defaults(host=None, name=None):
return vol.Schema(
{
vol.Required(CONF_HOST, default=host): str,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Optional(CONF_NAME, default=name): str,
}
)
async def validate_input(hass: core.HomeAssistant, data):
"""Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
device = DoorBird(data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD])
try:
status = await hass.async_add_executor_job(device.ready)
info = await hass.async_add_executor_job(device.info)
except urllib.error.HTTPError as err:
if err.code == 401:
raise InvalidAuth
raise CannotConnect
except OSError:
raise CannotConnect
if not status[0]:
raise CannotConnect
mac_addr = get_mac_address_from_doorstation_info(info)
# Return info that you want to store in the config entry.
return {"title": data[CONF_HOST], "mac_addr": mac_addr}
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for DoorBird."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
def __init__(self):
"""Initialize the DoorBird config flow."""
self.discovery_schema = {}
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if "base" not in errors:
await self.async_set_unique_id(info["mac_addr"])
self._abort_if_unique_id_configured()
return self.async_create_entry(title=info["title"], data=user_input)
data = self.discovery_schema or _schema_with_defaults()
return self.async_show_form(step_id="user", data_schema=data, errors=errors)
async def async_step_zeroconf(self, discovery_info):
"""Prepare configuration for a discovered doorbird device."""
macaddress = discovery_info["properties"]["macaddress"]
if macaddress[:6] != DOORBIRD_OUI:
return self.async_abort(reason="not_doorbird_device")
await self.async_set_unique_id(macaddress)
self._abort_if_unique_id_configured(
updates={CONF_HOST: discovery_info[CONF_HOST]}
)
chop_ending = "._axis-video._tcp.local."
friendly_hostname = discovery_info["name"]
if friendly_hostname.endswith(chop_ending):
friendly_hostname = friendly_hostname[: -len(chop_ending)]
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
self.context["title_placeholders"] = {
CONF_NAME: friendly_hostname,
CONF_HOST: discovery_info[CONF_HOST],
}
self.discovery_schema = _schema_with_defaults(
host=discovery_info[CONF_HOST], name=friendly_hostname
)
return await self.async_step_user()
async def async_step_import(self, user_input):
"""Handle import."""
return await self.async_step_user(user_input)
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle a option flow for doorbird."""
def __init__(self, config_entry: config_entries.ConfigEntry):
"""Initialize options flow."""
self.config_entry = config_entry
async def async_step_init(self, user_input=None):
"""Handle options flow."""
if user_input is not None:
events = [event.strip() for event in user_input[CONF_EVENTS].split(",")]
return self.async_create_entry(title="", data={CONF_EVENTS: events})
current_events = self.config_entry.options.get(CONF_EVENTS, [])
# We convert to a comma separated list for the UI
# since there really isn't anything better
options_schema = vol.Schema(
{vol.Optional(CONF_EVENTS, default=", ".join(current_events)): str}
)
return self.async_show_form(step_id="init", data_schema=options_schema)
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""

View File

@ -0,0 +1,17 @@
"""The DoorBird integration constants."""
DOMAIN = "doorbird"
PLATFORMS = ["switch", "camera"]
DOOR_STATION = "door_station"
DOOR_STATION_INFO = "door_station_info"
CONF_EVENTS = "events"
MANUFACTURER = "Bird Home Automation Group"
DOORBIRD_OUI = "1CCAE3"
DOORBIRD_INFO_KEY_FIRMWARE = "FIRMWARE"
DOORBIRD_INFO_KEY_BUILD_NUMBER = "BUILD_NUMBER"
DOORBIRD_INFO_KEY_DEVICE_TYPE = "DEVICE-TYPE"
DOORBIRD_INFO_KEY_RELAYS = "RELAYS"
DOORBIRD_INFO_KEY_PRIMARY_MAC_ADDR = "PRIMARY_MAC_ADDR"
DOORBIRD_INFO_KEY_WIFI_MAC_ADDR = "WIFI_MAC_ADDR"

View File

@ -0,0 +1,36 @@
"""The DoorBird integration base entity."""
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import Entity
from .const import (
DOORBIRD_INFO_KEY_BUILD_NUMBER,
DOORBIRD_INFO_KEY_DEVICE_TYPE,
DOORBIRD_INFO_KEY_FIRMWARE,
MANUFACTURER,
)
from .util import get_mac_address_from_doorstation_info
class DoorBirdEntity(Entity):
"""Base class for doorbird entities."""
def __init__(self, doorstation, doorstation_info):
"""Initialize the entity."""
super().__init__()
self._doorstation_info = doorstation_info
self._doorstation = doorstation
self._mac_addr = get_mac_address_from_doorstation_info(doorstation_info)
@property
def device_info(self):
"""Doorbird device info."""
firmware = self._doorstation_info[DOORBIRD_INFO_KEY_FIRMWARE]
firmware_build = self._doorstation_info[DOORBIRD_INFO_KEY_BUILD_NUMBER]
return {
"connections": {(dr.CONNECTION_NETWORK_MAC, self._mac_addr)},
"name": self._doorstation.name,
"manufacturer": MANUFACTURER,
"sw_version": f"{firmware} {firmware_build}",
"model": self._doorstation_info[DOORBIRD_INFO_KEY_DEVICE_TYPE],
}

View File

@ -2,7 +2,16 @@
"domain": "doorbird",
"name": "DoorBird",
"documentation": "https://www.home-assistant.io/integrations/doorbird",
"requirements": ["doorbirdpy==2.0.8"],
"dependencies": ["http", "logbook"],
"codeowners": ["@oblogic7"]
"requirements": [
"doorbirdpy==2.0.8"
],
"dependencies": [
"http",
"logbook"
],
"zeroconf": ["_axis-video._tcp.local."],
"codeowners": [
"@oblogic7", "@bdraco"
],
"config_flow": true
}

View File

@ -0,0 +1,34 @@
{
"options" : {
"step" : {
"init" : {
"data" : {
"events" : "Comma separated list of events."
},
"description" : "Add an comma separated event name for each event you wish to track. After entering them here, use the DoorBird app to assign them to a specific event. See the documentation at https://www.home-assistant.io/integrations/doorbird/#events. Example: somebody_pressed_the_button, motion"
}
}
},
"config" : {
"step" : {
"user" : {
"title" : "Connect to the DoorBird",
"data" : {
"password" : "Password",
"host" : "Host (IP Address)",
"name" : "Device Name",
"username" : "Username"
}
}
},
"abort" : {
"already_configured" : "This DoorBird is already configured"
},
"title" : "DoorBird",
"error" : {
"invalid_auth" : "Invalid authentication",
"unknown" : "Unexpected error",
"cannot_connect" : "Failed to connect, please try again"
}
}
}

View File

@ -5,33 +5,38 @@ import logging
from homeassistant.components.switch import SwitchDevice
import homeassistant.util.dt as dt_util
from . import DOMAIN as DOORBIRD_DOMAIN
from .const import DOMAIN, DOOR_STATION, DOOR_STATION_INFO
from .entity import DoorBirdEntity
_LOGGER = logging.getLogger(__name__)
IR_RELAY = "__ir_light__"
def setup_platform(hass, config, add_entities, discovery_info=None):
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the DoorBird switch platform."""
switches = []
entities = []
config_entry_id = config_entry.entry_id
for doorstation in hass.data[DOORBIRD_DOMAIN]:
relays = doorstation.device.info()["RELAYS"]
relays.append(IR_RELAY)
doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION]
doorstation_info = hass.data[DOMAIN][config_entry_id][DOOR_STATION_INFO]
for relay in relays:
switch = DoorBirdSwitch(doorstation, relay)
switches.append(switch)
relays = doorstation_info["RELAYS"]
relays.append(IR_RELAY)
add_entities(switches)
for relay in relays:
switch = DoorBirdSwitch(doorstation, doorstation_info, relay)
entities.append(switch)
async_add_entities(entities)
class DoorBirdSwitch(SwitchDevice):
class DoorBirdSwitch(DoorBirdEntity, SwitchDevice):
"""A relay in a DoorBird device."""
def __init__(self, doorstation, relay):
def __init__(self, doorstation, doorstation_info, relay):
"""Initialize a relay in a DoorBird device."""
super().__init__(doorstation, doorstation_info)
self._doorstation = doorstation
self._relay = relay
self._state = False
@ -41,6 +46,12 @@ class DoorBirdSwitch(SwitchDevice):
self._time = datetime.timedelta(minutes=5)
else:
self._time = datetime.timedelta(seconds=5)
self._unique_id = f"{self._mac_addr}_{self._relay}"
@property
def unique_id(self):
"""Switch unique id."""
return self._unique_id
@property
def name(self):

View File

@ -0,0 +1,19 @@
"""DoorBird integration utils."""
from .const import DOMAIN, DOOR_STATION
def get_mac_address_from_doorstation_info(doorstation_info):
"""Get the mac address depending on the device type."""
if "PRIMARY_MAC_ADDR" in doorstation_info:
return doorstation_info["PRIMARY_MAC_ADDR"]
return doorstation_info["WIFI_MAC_ADDR"]
def get_doorstation_by_token(hass, token):
"""Get doorstation by slug."""
for config_entry_id in hass.data[DOMAIN]:
doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION]
if token == doorstation.token:
return doorstation

View File

@ -24,6 +24,7 @@ FLOWS = [
"deconz",
"dialogflow",
"directv",
"doorbird",
"dynalite",
"ecobee",
"elgato",

View File

@ -7,7 +7,8 @@ To update, run python3 -m script.hassfest
ZEROCONF = {
"_axis-video._tcp.local.": [
"axis"
"axis",
"doorbird"
],
"_coap._udp.local.": [
"tradfri"

View File

@ -179,6 +179,9 @@ directpy==0.7
# homeassistant.components.updater
distro==1.4.0
# homeassistant.components.doorbird
doorbirdpy==2.0.8
# homeassistant.components.dsmr
dsmr_parser==0.18

View File

@ -0,0 +1 @@
"""Tests for the DoorBird integration."""

View File

@ -0,0 +1,258 @@
"""Test the DoorBird config flow."""
import urllib
from asynctest import MagicMock, patch
from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.doorbird import CONF_CUSTOM_URL, CONF_TOKEN
from homeassistant.components.doorbird.const import CONF_EVENTS, DOMAIN
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from tests.common import MockConfigEntry, init_recorder_component
VALID_CONFIG = {
CONF_HOST: "1.2.3.4",
CONF_USERNAME: "friend",
CONF_PASSWORD: "password",
CONF_NAME: "mydoorbird",
}
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)
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)
return doorbirdapi_mock
async def test_user_form(hass):
"""Test we get the user form."""
await hass.async_add_job(init_recorder_component, hass) # force in memory db
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {}
doorbirdapi = _get_mock_doorbirdapi_return_values(
ready=[True], info={"WIFI_MAC_ADDR": "macaddr"}
)
with patch(
"homeassistant.components.doorbird.config_flow.DoorBird",
return_value=doorbirdapi,
), patch(
"homeassistant.components.doorbird.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.doorbird.async_setup_entry", return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], VALID_CONFIG,
)
assert result2["type"] == "create_entry"
assert result2["title"] == "1.2.3.4"
assert result2["data"] == {
"host": "1.2.3.4",
"name": "mydoorbird",
"password": "password",
"username": "friend",
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_import(hass):
"""Test we get the form with import source."""
await hass.async_add_job(init_recorder_component, hass) # force in memory db
await setup.async_setup_component(hass, "persistent_notification", {})
import_config = VALID_CONFIG.copy()
import_config[CONF_EVENTS] = ["event1", "event2", "event3"]
import_config[CONF_TOKEN] = "imported_token"
import_config[
CONF_CUSTOM_URL
] = "http://legacy.custom.url/should/only/come/in/from/yaml"
doorbirdapi = _get_mock_doorbirdapi_return_values(
ready=[True], info={"WIFI_MAC_ADDR": "macaddr"}
)
with patch(
"homeassistant.components.doorbird.config_flow.DoorBird",
return_value=doorbirdapi,
), patch("homeassistant.components.logbook.async_setup", return_value=True), patch(
"homeassistant.components.doorbird.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.doorbird.async_setup_entry", return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=import_config,
)
assert result["type"] == "create_entry"
assert result["title"] == "1.2.3.4"
assert result["data"] == {
"host": "1.2.3.4",
"name": "mydoorbird",
"password": "password",
"username": "friend",
"events": ["event1", "event2", "event3"],
"token": "imported_token",
# This will go away once we convert to cloud hooks
"hass_url_override": "http://legacy.custom.url/should/only/come/in/from/yaml",
}
# It is not possible to import options at this time
# so they end up in the config entry data and are
# used a fallback when they are not in options
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_zeroconf_wrong_oui(hass):
"""Test we abort when we get the wrong OUI via zeroconf."""
await hass.async_add_job(init_recorder_component, hass) # force in memory db
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data={
"properties": {"macaddress": "notdoorbirdoui"},
"name": "Doorstation - abc123._axis-video._tcp.local.",
},
)
assert result["type"] == "abort"
async def test_form_zeroconf_correct_oui(hass):
"""Test we can setup from zeroconf with the correct OUI source."""
await hass.async_add_job(init_recorder_component, hass) # force in memory db
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data={
"properties": {"macaddress": "1CCAE3DOORBIRD"},
"name": "Doorstation - abc123._axis-video._tcp.local.",
"host": "192.168.1.5",
},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
doorbirdapi = _get_mock_doorbirdapi_return_values(
ready=[True], info={"WIFI_MAC_ADDR": "macaddr"}
)
with patch(
"homeassistant.components.doorbird.config_flow.DoorBird",
return_value=doorbirdapi,
), patch("homeassistant.components.logbook.async_setup", return_value=True), patch(
"homeassistant.components.doorbird.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.doorbird.async_setup_entry", return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], VALID_CONFIG
)
assert result2["type"] == "create_entry"
assert result2["title"] == "1.2.3.4"
assert result2["data"] == {
"host": "1.2.3.4",
"name": "mydoorbird",
"password": "password",
"username": "friend",
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_user_cannot_connect(hass):
"""Test we handle cannot connect error."""
await hass.async_add_job(init_recorder_component, hass) # force in memory db
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
doorbirdapi = _get_mock_doorbirdapi_side_effects(ready=OSError)
with patch(
"homeassistant.components.doorbird.config_flow.DoorBird",
return_value=doorbirdapi,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], VALID_CONFIG,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_user_invalid_auth(hass):
"""Test we handle cannot invalid auth error."""
await hass.async_add_job(init_recorder_component, hass) # force in memory db
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
mock_urllib_error = urllib.error.HTTPError(
"http://xyz.tld", 401, "login failed", {}, None
)
doorbirdapi = _get_mock_doorbirdapi_side_effects(ready=mock_urllib_error)
with patch(
"homeassistant.components.doorbird.config_flow.DoorBird",
return_value=doorbirdapi,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], VALID_CONFIG,
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "invalid_auth"}
async def test_options_flow(hass):
"""Test config flow options."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="abcde12345",
data=VALID_CONFIG,
options={CONF_EVENTS: ["event1", "event2", "event3"]},
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.doorbird.async_setup_entry", return_value=True
):
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={CONF_EVENTS: "eventa, eventc, eventq"}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options == {CONF_EVENTS: ["eventa", "eventc", "eventq"]}

View File

@ -62,7 +62,10 @@ async def test_setup(hass, mock_zeroconf):
assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
assert len(mock_service_browser.mock_calls) == len(zc_gen.ZEROCONF)
assert len(mock_config_flow.mock_calls) == len(zc_gen.ZEROCONF) * 2
expected_flow_calls = 0
for matching_components in zc_gen.ZEROCONF.values():
expected_flow_calls += len(matching_components)
assert len(mock_config_flow.mock_calls) == expected_flow_calls * 2
async def test_homekit_match_partial(hass, mock_zeroconf):