From f9a7c641066c966d023242d4ab5a29ecd2bf7402 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Mar 2020 04:14:21 -0500 Subject: [PATCH] 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 * without getting the hooks its not so useful * Update homeassistant/components/doorbird/config_flow.py Co-Authored-By: Paulus Schoutsen * fix copy pasta * remove identifiers since its in connections * self review fixes Co-authored-by: Paulus Schoutsen --- CODEOWNERS | 2 +- .../components/doorbird/.translations/en.json | 34 +++ homeassistant/components/doorbird/__init__.py | 208 +++++++++----- homeassistant/components/doorbird/camera.py | 96 ++++--- .../components/doorbird/config_flow.py | 156 +++++++++++ homeassistant/components/doorbird/const.py | 17 ++ homeassistant/components/doorbird/entity.py | 36 +++ .../components/doorbird/manifest.json | 15 +- .../components/doorbird/strings.json | 34 +++ homeassistant/components/doorbird/switch.py | 35 ++- homeassistant/components/doorbird/util.py | 19 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/zeroconf.py | 3 +- requirements_test_all.txt | 3 + tests/components/doorbird/__init__.py | 1 + tests/components/doorbird/test_config_flow.py | 258 ++++++++++++++++++ tests/components/zeroconf/test_init.py | 5 +- 17 files changed, 802 insertions(+), 121 deletions(-) create mode 100644 homeassistant/components/doorbird/.translations/en.json create mode 100644 homeassistant/components/doorbird/config_flow.py create mode 100644 homeassistant/components/doorbird/const.py create mode 100644 homeassistant/components/doorbird/entity.py create mode 100644 homeassistant/components/doorbird/strings.json create mode 100644 homeassistant/components/doorbird/util.py create mode 100644 tests/components/doorbird/__init__.py create mode 100644 tests/components/doorbird/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 5cc19809a47..86ad7f5c2db 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/doorbird/.translations/en.json b/homeassistant/components/doorbird/.translations/en.json new file mode 100644 index 00000000000..caf3177c681 --- /dev/null +++ b/homeassistant/components/doorbird/.translations/en.json @@ -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" + } + } +} diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index 049681a4aa6..f762a722f2f 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -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) diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index 4bf3a6e060f..bf999489589 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -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 diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py new file mode 100644 index 00000000000..37d46c23a9d --- /dev/null +++ b/homeassistant/components/doorbird/config_flow.py @@ -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.""" diff --git a/homeassistant/components/doorbird/const.py b/homeassistant/components/doorbird/const.py new file mode 100644 index 00000000000..3b639fc8dca --- /dev/null +++ b/homeassistant/components/doorbird/const.py @@ -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" diff --git a/homeassistant/components/doorbird/entity.py b/homeassistant/components/doorbird/entity.py new file mode 100644 index 00000000000..44cbb1f42de --- /dev/null +++ b/homeassistant/components/doorbird/entity.py @@ -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], + } diff --git a/homeassistant/components/doorbird/manifest.json b/homeassistant/components/doorbird/manifest.json index 1703557cc9e..e0aef80ab61 100644 --- a/homeassistant/components/doorbird/manifest.json +++ b/homeassistant/components/doorbird/manifest.json @@ -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 } diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json new file mode 100644 index 00000000000..caf3177c681 --- /dev/null +++ b/homeassistant/components/doorbird/strings.json @@ -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" + } + } +} diff --git a/homeassistant/components/doorbird/switch.py b/homeassistant/components/doorbird/switch.py index 7a0dfa82e76..9f292803b8b 100644 --- a/homeassistant/components/doorbird/switch.py +++ b/homeassistant/components/doorbird/switch.py @@ -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): diff --git a/homeassistant/components/doorbird/util.py b/homeassistant/components/doorbird/util.py new file mode 100644 index 00000000000..7db9063580d --- /dev/null +++ b/homeassistant/components/doorbird/util.py @@ -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 diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index cc36af05da4..8c03702e8f9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -24,6 +24,7 @@ FLOWS = [ "deconz", "dialogflow", "directv", + "doorbird", "dynalite", "ecobee", "elgato", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 1a9972e9a6e..968a73588e7 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -7,7 +7,8 @@ To update, run python3 -m script.hassfest ZEROCONF = { "_axis-video._tcp.local.": [ - "axis" + "axis", + "doorbird" ], "_coap._udp.local.": [ "tradfri" diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b671e7bd660..d5b56f9c14c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/doorbird/__init__.py b/tests/components/doorbird/__init__.py new file mode 100644 index 00000000000..57bf4c04e39 --- /dev/null +++ b/tests/components/doorbird/__init__.py @@ -0,0 +1 @@ +"""Tests for the DoorBird integration.""" diff --git a/tests/components/doorbird/test_config_flow.py b/tests/components/doorbird/test_config_flow.py new file mode 100644 index 00000000000..7a70aec9041 --- /dev/null +++ b/tests/components/doorbird/test_config_flow.py @@ -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"]} diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index c5790dc718c..a74c81ba307 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -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):