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
parent
49ebea2be3
commit
f9a7c64106
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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."""
|
|
@ -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"
|
|
@ -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],
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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):
|
||||
|
|
|
@ -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
|
|
@ -24,6 +24,7 @@ FLOWS = [
|
|||
"deconz",
|
||||
"dialogflow",
|
||||
"directv",
|
||||
"doorbird",
|
||||
"dynalite",
|
||||
"ecobee",
|
||||
"elgato",
|
||||
|
|
|
@ -7,7 +7,8 @@ To update, run python3 -m script.hassfest
|
|||
|
||||
ZEROCONF = {
|
||||
"_axis-video._tcp.local.": [
|
||||
"axis"
|
||||
"axis",
|
||||
"doorbird"
|
||||
],
|
||||
"_coap._udp.local.": [
|
||||
"tradfri"
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
"""Tests for the DoorBird integration."""
|
|
@ -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"]}
|
|
@ -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):
|
||||
|
|
Loading…
Reference in New Issue