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>
This commit is contained in:
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/digital_ocean/* @fabaff
homeassistant/components/directv/* @ctalkington homeassistant/components/directv/* @ctalkington
homeassistant/components/discogs/* @thibmaek homeassistant/components/discogs/* @thibmaek
homeassistant/components/doorbird/* @oblogic7 homeassistant/components/doorbird/* @oblogic7 @bdraco
homeassistant/components/dsmr_reader/* @depl0y homeassistant/components/dsmr_reader/* @depl0y
homeassistant/components/dweet/* @fabaff homeassistant/components/dweet/* @fabaff
homeassistant/components/dynalite/* @ziv1234 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.""" """Support for DoorBird devices."""
import asyncio
import logging import logging
import urllib
from urllib.error import HTTPError from urllib.error import HTTPError
from doorbirdpy import DoorBird from doorbirdpy import DoorBird
@ -7,6 +9,7 @@ import voluptuous as vol
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
from homeassistant.components.logbook import log_entry from homeassistant.components.logbook import log_entry
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_DEVICES, CONF_DEVICES,
CONF_HOST, CONF_HOST,
@ -15,17 +18,19 @@ from homeassistant.const import (
CONF_TOKEN, CONF_TOKEN,
CONF_USERNAME, CONF_USERNAME,
) )
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.util import dt as dt_util, slugify 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}" API_URL = f"/api/{DOMAIN}"
CONF_CUSTOM_URL = "hass_url_override" CONF_CUSTOM_URL = "hass_url_override"
CONF_EVENTS = "events"
RESET_DEVICE_FAVORITES = "doorbird_reset_favorites" 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.""" """Set up the DoorBird component."""
hass.data.setdefault(DOMAIN, {})
# Provide an endpoint for the doorstations to call to trigger events # Provide an endpoint for the doorstations to call to trigger events
hass.http.register_view(DoorBirdRequestView) 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]): hass.async_create_task(
device_ip = doorstation_config.get(CONF_HOST) hass.config_entries.flow.async_init(
username = doorstation_config.get(CONF_USERNAME) DOMAIN, context={"source": SOURCE_IMPORT}, data=doorstation_config,
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",
) )
)
return False
hass.data[DOMAIN] = doorstations
def _reset_device_favorites_handler(event): def _reset_device_favorites_handler(event):
"""Handle clearing favorites on device.""" """Handle clearing favorites on device."""
@ -129,6 +86,7 @@ def setup(hass, config):
if doorstation is None: if doorstation is None:
_LOGGER.error("Device not found for provided token.") _LOGGER.error("Device not found for provided token.")
return
# Clear webhooks # Clear webhooks
favorites = doorstation.device.favorites() favorites = doorstation.device.favorites()
@ -137,16 +95,126 @@ def setup(hass, config):
for favorite_id in favorites[favorite_type]: for favorite_id in favorites[favorite_type]:
doorstation.device.delete_favorite(favorite_type, favorite_id) 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 return True
def get_doorstation_by_token(hass, token): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Get doorstation by slug.""" """Set up DoorBird from a config entry."""
for doorstation in hass.data[DOMAIN]:
if token == doorstation.token: _async_import_options_from_data_if_missing(hass, entry)
return doorstation
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: class ConfiguredDoorBird:
@ -157,7 +225,7 @@ class ConfiguredDoorBird:
self._name = name self._name = name
self._device = device self._device = device
self._custom_url = custom_url self._custom_url = custom_url
self._events = events self.events = events
self._token = token self._token = token
@property @property
@ -189,7 +257,7 @@ class ConfiguredDoorBird:
if self.custom_url is not None: if self.custom_url is not None:
hass_url = self.custom_url hass_url = self.custom_url
for event in self._events: for event in self.events:
event = self._get_event_name(event) event = self._get_event_name(event)
self._register_event(hass_url, 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 from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.util.dt as dt_util 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_VISITOR_INTERVAL = datetime.timedelta(minutes=2)
_LAST_MOTION_INTERVAL = datetime.timedelta(minutes=1) _LAST_MOTION_INTERVAL = datetime.timedelta(seconds=30)
_LIVE_INTERVAL = datetime.timedelta(seconds=1) _LIVE_INTERVAL = datetime.timedelta(seconds=45)
_LOGGER = logging.getLogger(__name__) _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.""" """Set up the DoorBird camera platform."""
for doorstation in hass.data[DOORBIRD_DOMAIN]: config_entry_id = config_entry.entry_id
device = doorstation.device doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION]
async_add_entities( doorstation_info = hass.data[DOMAIN][config_entry_id][DOOR_STATION_INFO]
[ device = doorstation.device
DoorBirdCamera(
device.live_image_url, async_add_entities(
f"{doorstation.name} Live", [
_LIVE_INTERVAL, DoorBirdCamera(
device.rtsp_live_video_url, doorstation,
), doorstation_info,
DoorBirdCamera( device.live_image_url,
device.history_image_url(1, "doorbell"), "live",
f"{doorstation.name} Last Ring", f"{doorstation.name} Live",
_LAST_VISITOR_INTERVAL, _LIVE_INTERVAL,
), device.rtsp_live_video_url,
DoorBirdCamera( ),
device.history_image_url(1, "motionsensor"), DoorBirdCamera(
f"{doorstation.name} Last Motion", doorstation,
_LAST_MOTION_INTERVAL, 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.""" """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.""" """Initialize the camera on a DoorBird device."""
super().__init__(doorstation, doorstation_info)
self._url = url self._url = url
self._stream_url = stream_url self._stream_url = stream_url
self._name = name self._name = name
@ -57,12 +80,17 @@ class DoorBirdCamera(Camera):
self._supported_features = SUPPORT_STREAM if self._stream_url else 0 self._supported_features = SUPPORT_STREAM if self._stream_url else 0
self._interval = interval or datetime.timedelta self._interval = interval or datetime.timedelta
self._last_update = datetime.datetime.min self._last_update = datetime.datetime.min
super().__init__() self._unique_id = f"{self._mac_addr}_{camera_id}"
async def stream_source(self): async def stream_source(self):
"""Return the stream source.""" """Return the stream source."""
return self._stream_url return self._stream_url
@property
def unique_id(self):
"""Camera Unique id."""
return self._unique_id
@property @property
def supported_features(self): def supported_features(self):
"""Return supported features.""" """Return supported features."""
@ -89,8 +117,10 @@ class DoorBirdCamera(Camera):
self._last_update = now self._last_update = now
return self._last_image return self._last_image
except asyncio.TimeoutError: except asyncio.TimeoutError:
_LOGGER.error("Camera image timed out") _LOGGER.error("DoorBird %s: Camera image timed out", self._name)
return self._last_image return self._last_image
except aiohttp.ClientError as error: 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 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", "domain": "doorbird",
"name": "DoorBird", "name": "DoorBird",
"documentation": "https://www.home-assistant.io/integrations/doorbird", "documentation": "https://www.home-assistant.io/integrations/doorbird",
"requirements": ["doorbirdpy==2.0.8"], "requirements": [
"dependencies": ["http", "logbook"], "doorbirdpy==2.0.8"
"codeowners": ["@oblogic7"] ],
"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 from homeassistant.components.switch import SwitchDevice
import homeassistant.util.dt as dt_util 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__) _LOGGER = logging.getLogger(__name__)
IR_RELAY = "__ir_light__" 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.""" """Set up the DoorBird switch platform."""
switches = [] entities = []
config_entry_id = config_entry.entry_id
for doorstation in hass.data[DOORBIRD_DOMAIN]: doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION]
relays = doorstation.device.info()["RELAYS"] doorstation_info = hass.data[DOMAIN][config_entry_id][DOOR_STATION_INFO]
relays.append(IR_RELAY)
for relay in relays: relays = doorstation_info["RELAYS"]
switch = DoorBirdSwitch(doorstation, relay) relays.append(IR_RELAY)
switches.append(switch)
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.""" """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.""" """Initialize a relay in a DoorBird device."""
super().__init__(doorstation, doorstation_info)
self._doorstation = doorstation self._doorstation = doorstation
self._relay = relay self._relay = relay
self._state = False self._state = False
@ -41,6 +46,12 @@ class DoorBirdSwitch(SwitchDevice):
self._time = datetime.timedelta(minutes=5) self._time = datetime.timedelta(minutes=5)
else: else:
self._time = datetime.timedelta(seconds=5) 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 @property
def name(self): 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", "deconz",
"dialogflow", "dialogflow",
"directv", "directv",
"doorbird",
"dynalite", "dynalite",
"ecobee", "ecobee",
"elgato", "elgato",

View File

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

View File

@ -179,6 +179,9 @@ directpy==0.7
# homeassistant.components.updater # homeassistant.components.updater
distro==1.4.0 distro==1.4.0
# homeassistant.components.doorbird
doorbirdpy==2.0.8
# homeassistant.components.dsmr # homeassistant.components.dsmr
dsmr_parser==0.18 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 await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
assert len(mock_service_browser.mock_calls) == len(zc_gen.ZEROCONF) 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): async def test_homekit_match_partial(hass, mock_zeroconf):