Add authentication support to MotionMount integration (#126487)

This commit is contained in:
RJPoelstra 2025-01-28 16:45:19 +01:00 committed by GitHub
parent 3f013ab620
commit b16c3a55a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 674 additions and 161 deletions

View File

@ -7,9 +7,9 @@ import socket
import motionmount
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.device_registry import format_mac
from .const import DOMAIN, EMPTY_MAC
@ -48,6 +48,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}"
)
# Check we're properly authenticated or be able to become so
if not mm.is_authenticated:
if CONF_PIN not in entry.data:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="no_pin_provided",
)
pin = entry.data[CONF_PIN]
await mm.authenticate(pin)
if not mm.is_authenticated:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="incorrect_pin",
)
# Store an API object for your platforms to access
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = mm

View File

@ -1,5 +1,7 @@
"""Config flow for Vogel's MotionMount."""
import asyncio
from collections.abc import Mapping
import logging
import socket
from typing import Any
@ -9,10 +11,11 @@ import voluptuous as vol
from homeassistant.config_entries import (
DEFAULT_DISCOVERY_UNIQUE_ID,
SOURCE_REAUTH,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_UUID
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT, CONF_UUID
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@ -34,7 +37,9 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Set up the instance."""
self.discovery_info: dict[str, Any] = {}
self.connection_data: dict[str, Any] = {}
self.backoff_task: asyncio.Task | None = None
self.backoff_time: int = 0
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@ -43,23 +48,16 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is None:
return self._show_setup_form()
self.connection_data.update(user_input)
info = {}
try:
info = await self._validate_input(user_input)
info = await self._validate_input_connect(self.connection_data)
except (ConnectionError, socket.gaierror):
return self.async_abort(reason="cannot_connect")
except TimeoutError:
return self.async_abort(reason="time_out")
except motionmount.NotConnectedError:
return self.async_abort(reason="not_connected")
except motionmount.MotionMountResponseError:
# This is most likely due to missing support for the mac address property
# Abort if the handler has config entries already
if self._async_current_entries():
return self.async_abort(reason="already_configured")
# Otherwise we try to continue with the generic uid
info[CONF_UUID] = DEFAULT_DISCOVERY_UNIQUE_ID
# If the device mac is valid we use it, otherwise we use the default id
if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC:
@ -67,17 +65,22 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN):
else:
unique_id = DEFAULT_DISCOVERY_UNIQUE_ID
name = info.get(CONF_NAME, user_input[CONF_HOST])
name = info.get(CONF_NAME, self.connection_data[CONF_HOST])
self.connection_data[CONF_NAME] = name
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured(
updates={
CONF_HOST: user_input[CONF_HOST],
CONF_PORT: user_input[CONF_PORT],
CONF_HOST: self.connection_data[CONF_HOST],
CONF_PORT: self.connection_data[CONF_PORT],
}
)
return self.async_create_entry(title=name, data=user_input)
if not info[CONF_PIN]:
# We need a pin to authenticate
return await self.async_step_auth()
# No pin is needed
return self._create_or_update_entry()
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
@ -91,7 +94,7 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN):
name = discovery_info.name.removesuffix(f".{zctype}")
unique_id = discovery_info.properties.get("mac")
self.discovery_info.update(
self.connection_data.update(
{
CONF_HOST: host,
CONF_PORT: port,
@ -114,16 +117,13 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN):
self.context.update({"title_placeholders": {"name": name}})
try:
info = await self._validate_input(self.discovery_info)
info = await self._validate_input_connect(self.connection_data)
except (ConnectionError, socket.gaierror):
return self.async_abort(reason="cannot_connect")
except TimeoutError:
return self.async_abort(reason="time_out")
except motionmount.NotConnectedError:
return self.async_abort(reason="not_connected")
except motionmount.MotionMountResponseError:
info = {}
# We continue as we want to be able to connect with older FW that does not support MAC address
# If the device supplied as with a valid MAC we use that
if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC:
@ -137,6 +137,10 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN):
else:
await self._async_handle_discovery_without_unique_id()
if not info[CONF_PIN]:
# We need a pin to authenticate
return await self.async_step_auth()
# No pin is needed
return await self.async_step_zeroconf_confirm()
async def async_step_zeroconf_confirm(
@ -146,16 +150,82 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(
step_id="zeroconf_confirm",
description_placeholders={CONF_NAME: self.discovery_info[CONF_NAME]},
description_placeholders={CONF_NAME: self.connection_data[CONF_NAME]},
errors={},
)
return self.async_create_entry(
title=self.discovery_info[CONF_NAME],
data=self.discovery_info,
return self._create_or_update_entry()
async def async_step_reauth(
self, user_input: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle re-authentication."""
reauth_entry = self._get_reauth_entry()
self.connection_data.update(reauth_entry.data)
return await self.async_step_auth()
async def async_step_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle authentication form."""
errors = {}
if user_input is not None:
self.connection_data[CONF_PIN] = user_input[CONF_PIN]
# Validate pin code
valid_or_wait_time = await self._validate_input_pin(self.connection_data)
if valid_or_wait_time is True:
return self._create_or_update_entry()
if type(valid_or_wait_time) is int:
self.backoff_time = valid_or_wait_time
self.backoff_task = self.hass.async_create_task(
self._backoff(valid_or_wait_time)
)
return await self.async_step_backoff()
errors[CONF_PIN] = CONF_PIN
return self.async_show_form(
step_id="auth",
data_schema=vol.Schema(
{
vol.Required(CONF_PIN): vol.All(int, vol.Range(min=1, max=9999)),
}
),
errors=errors,
)
async def _validate_input(self, data: dict) -> dict[str, Any]:
async def async_step_backoff(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle backoff progress."""
if not self.backoff_task or self.backoff_task.done():
self.backoff_task = None
return self.async_show_progress_done(next_step_id="auth")
return self.async_show_progress(
step_id="backoff",
description_placeholders={
"timeout": str(self.backoff_time),
},
progress_action="progress_action",
progress_task=self.backoff_task,
)
def _create_or_update_entry(self) -> ConfigFlowResult:
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
return self.async_update_reload_and_abort(
reauth_entry, data_updates=self.connection_data
)
return self.async_create_entry(
title=self.connection_data[CONF_NAME],
data=self.connection_data,
)
async def _validate_input_connect(self, data: dict) -> dict[str, Any]:
"""Validate the user input allows us to connect."""
mm = motionmount.MotionMount(data[CONF_HOST], data[CONF_PORT])
@ -164,7 +234,33 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN):
finally:
await mm.disconnect()
return {CONF_UUID: format_mac(mm.mac.hex()), CONF_NAME: mm.name}
return {
CONF_UUID: format_mac(mm.mac.hex()),
CONF_NAME: mm.name,
CONF_PIN: mm.is_authenticated,
}
async def _validate_input_pin(self, data: dict) -> bool | int:
"""Validate the user input allows us to authenticate."""
mm = motionmount.MotionMount(data[CONF_HOST], data[CONF_PORT])
try:
await mm.connect()
can_authenticate = mm.can_authenticate
if can_authenticate is True:
await mm.authenticate(data[CONF_PIN])
else:
# The backoff is running, return the remaining time
return can_authenticate
finally:
await mm.disconnect()
can_authenticate = mm.can_authenticate
if can_authenticate is True:
return mm.is_authenticated
return can_authenticate
def _show_setup_form(
self, errors: dict[str, str] | None = None
@ -180,3 +276,9 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN):
),
errors=errors or {},
)
async def _backoff(self, time: int) -> None:
while time > 0:
time -= 1
self.backoff_time = time
await asyncio.sleep(1)

View File

@ -1,13 +1,12 @@
"""Support for MotionMount sensors."""
import logging
import socket
from typing import TYPE_CHECKING
import motionmount
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS
from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_PIN
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
from homeassistant.helpers.entity import Entity
@ -26,6 +25,11 @@ class MotionMountEntity(Entity):
def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None:
"""Initialize general MotionMount entity."""
self.mm = mm
self.config_entry = config_entry
# We store the pin, as we might need it during reconnect
self.pin = config_entry.data[CONF_PIN]
mac = format_mac(mm.mac.hex())
# Create a base unique id
@ -74,23 +78,3 @@ class MotionMountEntity(Entity):
self.mm.remove_listener(self.async_write_ha_state)
self.mm.remove_listener(self.update_name)
await super().async_will_remove_from_hass()
async def _ensure_connected(self) -> bool:
"""Make sure there is a connection with the MotionMount.
Returns false if the connection failed to be ensured.
"""
if self.mm.is_connected:
return True
try:
await self.mm.connect()
except (ConnectionError, TimeoutError, socket.gaierror):
# We're not interested in exceptions here. In case of a failed connection
# the try/except from the caller will report it.
# The purpose of `_ensure_connected()` is only to make sure we try to
# reconnect, where failures should not be logged each time
return False
else:
_LOGGER.warning("Successfully reconnected to MotionMount")
return True

View File

@ -51,6 +51,38 @@ class MotionMountPresets(MotionMountEntity, SelectEntity):
self._attr_options = options
async def _ensure_connected(self) -> bool:
"""Make sure there is a connection with the MotionMount.
Returns false if the connection failed to be ensured.
"""
if self.mm.is_connected:
return True
try:
await self.mm.connect()
except (ConnectionError, TimeoutError, socket.gaierror):
# We're not interested in exceptions here. In case of a failed connection
# the try/except from the caller will report it.
# The purpose of `_ensure_connected()` is only to make sure we try to
# reconnect, where failures should not be logged each time
return False
# Check we're properly authenticated or be able to become so
if not self.mm.is_authenticated:
if self.pin is None:
await self.mm.disconnect()
self.config_entry.async_start_reauth(self.hass)
return False
await self.mm.authenticate(self.pin)
if not self.mm.is_authenticated:
self.pin = None
await self.mm.disconnect()
self.config_entry.async_start_reauth(self.hass)
return False
_LOGGER.debug("Successfully reconnected to MotionMount")
return True
async def async_update(self) -> None:
"""Get latest state from MotionMount."""
if not await self._ensure_connected():

View File

@ -1,4 +1,7 @@
{
"common": {
"incorrect_pin": "Pin is not correct"
},
"config": {
"flow_title": "{name}",
"step": {
@ -13,15 +16,33 @@
"zeroconf_confirm": {
"description": "Do you want to set up {name}?",
"title": "Discovered MotionMount"
},
"auth": {
"title": "Authenticate to your MotionMount",
"description": "Your MotionMount requires a pin to operate.",
"data": {
"pin": "[%key:common::config_flow::data::pin%]"
}
},
"backoff": {
"title": "Authenticate to your MotionMount",
"description": "Too many incorrect pin attempts."
}
},
"error": {
"pin": "[%key:component::motionmount::common::incorrect_pin%]"
},
"progress": {
"progress_action": "Too many incorrect pin attempts. Please wait {timeout} s..."
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"time_out": "Failed to connect due to a time out.",
"time_out": "[%key:common::config_flow::error::timeout_connect%]",
"not_connected": "Failed to connect.",
"invalid_response": "Failed to connect due to an invalid response from the MotionMount."
"invalid_response": "Failed to connect due to an invalid response from the MotionMount.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"entity": {
@ -60,6 +81,12 @@
"exceptions": {
"failed_communication": {
"message": "Failed to communicate with MotionMount"
},
"no_pin_provided": {
"message": "No pin provided"
},
"incorrect_pin": {
"message": "[%key:component::motionmount::common::incorrect_pin%]"
}
}
}

View File

@ -2,7 +2,7 @@
from ipaddress import ip_address
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
HOST = "192.168.1.31"
@ -21,6 +21,8 @@ MOCK_USER_INPUT = {
CONF_PORT: PORT,
}
MOCK_PIN_INPUT = {CONF_PIN: 1234}
MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = ZeroconfServiceInfo(
type=TVM_ZEROCONF_SERVICE_TYPE,
name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}",

View File

@ -1,20 +1,23 @@
"""Tests for the Vogel's MotionMount config flow."""
import dataclasses
from datetime import timedelta
import socket
from unittest.mock import MagicMock, PropertyMock
from freezegun.api import FrozenDateTimeFactory
import motionmount
import pytest
from homeassistant.components.motionmount.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import (
HOST,
MOCK_PIN_INPUT,
MOCK_USER_INPUT,
MOCK_ZEROCONF_TVM_SERVICE_INFO_V1,
MOCK_ZEROCONF_TVM_SERVICE_INFO_V2,
@ -24,23 +27,12 @@ from . import (
ZEROCONF_NAME,
)
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
MAC = bytes.fromhex("c4dd57f8a55f")
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
async def test_show_user_form(hass: HomeAssistant) -> None:
"""Test that the user set up form is served."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["step_id"] == "user"
assert result["type"] is FlowResultType.FORM
async def test_user_connection_error(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
@ -117,33 +109,6 @@ async def test_user_not_connected_error(
assert result["reason"] == "not_connected"
async def test_user_response_error_single_device_old_ce_old_new_pro(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that the flow creates an entry when there is a response error."""
mock_motionmount_config_flow.connect.side_effect = (
motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound)
)
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=user_input,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == HOST
assert result["data"]
assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_PORT] == PORT
assert result["result"]
async def test_user_response_error_single_device_new_ce_old_pro(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
@ -199,30 +164,6 @@ async def test_user_response_error_single_device_new_ce_new_pro(
assert result["result"].unique_id == ZEROCONF_MAC
async def test_user_response_error_multi_device_old_ce_old_new_pro(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that the flow is aborted when there are multiple devices."""
mock_config_entry.add_to_hass(hass)
mock_motionmount_config_flow.connect.side_effect = (
motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound)
)
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=user_input,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_user_response_error_multi_device_new_ce_new_pro(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
@ -246,6 +187,53 @@ async def test_user_response_error_multi_device_new_ce_new_pro(
assert result["reason"] == "already_configured"
async def test_user_response_authentication_needed(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that authentication is requested when needed."""
type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME)
type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC)
type(mock_motionmount_config_flow).is_authenticated = PropertyMock(
return_value=False
)
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=user_input,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
# Now simulate the user entered the correct pin to finalize the test
type(mock_motionmount_config_flow).is_authenticated = PropertyMock(
return_value=True
)
type(mock_motionmount_config_flow).can_authenticate = PropertyMock(
return_value=True
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_PIN_INPUT.copy(),
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == ZEROCONF_NAME
assert result["data"]
assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_PORT] == PORT
assert result["result"]
assert result["result"].unique_id == ZEROCONF_MAC
async def test_zeroconf_connection_error(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
@ -322,48 +310,6 @@ async def test_zeroconf_not_connected_error(
assert result["reason"] == "not_connected"
async def test_show_zeroconf_form_old_ce_old_pro(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that the zeroconf confirmation form is served."""
mock_motionmount_config_flow.connect.side_effect = (
motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound)
)
discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=discovery_info,
)
assert result["step_id"] == "zeroconf_confirm"
assert result["type"] is FlowResultType.FORM
assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"}
async def test_show_zeroconf_form_old_ce_new_pro(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that the zeroconf confirmation form is served."""
mock_motionmount_config_flow.connect.side_effect = (
motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound)
)
discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=discovery_info,
)
assert result["step_id"] == "zeroconf_confirm"
assert result["type"] is FlowResultType.FORM
assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"}
async def test_show_zeroconf_form_new_ce_old_pro(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
@ -384,6 +330,21 @@ async def test_show_zeroconf_form_new_ce_old_pro(
assert result["type"] is FlowResultType.FORM
assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == ZEROCONF_NAME
assert result["data"]
assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME
assert result["data"][CONF_PORT] == PORT
assert result["data"][CONF_NAME] == ZEROCONF_NAME
assert result["result"]
assert result["result"].unique_id is None
async def test_show_zeroconf_form_new_ce_new_pro(
hass: HomeAssistant,
@ -403,6 +364,21 @@ async def test_show_zeroconf_form_new_ce_new_pro(
assert result["type"] is FlowResultType.FORM
assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == ZEROCONF_NAME
assert result["data"]
assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME
assert result["data"][CONF_PORT] == PORT
assert result["data"][CONF_NAME] == ZEROCONF_NAME
assert result["result"]
assert result["result"].unique_id == ZEROCONF_MAC
async def test_zeroconf_device_exists_abort(
hass: HomeAssistant,
@ -423,6 +399,346 @@ async def test_zeroconf_device_exists_abort(
assert result["reason"] == "already_configured"
async def test_zeroconf_authentication_needed(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that authentication is requested when needed."""
type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC)
type(mock_motionmount_config_flow).is_authenticated = PropertyMock(
return_value=False
)
discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=discovery_info,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
# Now simulate the user entered the correct pin to finalize the test
type(mock_motionmount_config_flow).is_authenticated = PropertyMock(
return_value=True
)
type(mock_motionmount_config_flow).can_authenticate = PropertyMock(
return_value=True
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_PIN_INPUT.copy(),
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == ZEROCONF_NAME
assert result["data"]
assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME
assert result["data"][CONF_PORT] == PORT
assert result["data"][CONF_NAME] == ZEROCONF_NAME
assert result["result"]
assert result["result"].unique_id == ZEROCONF_MAC
async def test_authentication_incorrect_then_correct_pin(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that authentication is requested when needed."""
type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME)
type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC)
type(mock_motionmount_config_flow).is_authenticated = PropertyMock(
return_value=False
)
type(mock_motionmount_config_flow).can_authenticate = PropertyMock(
return_value=True
)
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=user_input,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_PIN_INPUT.copy(),
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
assert result["errors"]
assert result["errors"][CONF_PIN] == CONF_PIN
# Now simulate the user entered the correct pin to finalize the test
type(mock_motionmount_config_flow).is_authenticated = PropertyMock(
return_value=True
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_PIN_INPUT.copy(),
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == ZEROCONF_NAME
assert result["data"]
assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_PORT] == PORT
assert result["result"]
assert result["result"].unique_id == ZEROCONF_MAC
async def test_authentication_first_incorrect_pin_to_backoff(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_motionmount_config_flow: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that authentication is requested when needed."""
type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME)
type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC)
type(mock_motionmount_config_flow).is_authenticated = PropertyMock(
return_value=False
)
type(mock_motionmount_config_flow).can_authenticate = PropertyMock(
side_effect=[True, 1]
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=MOCK_USER_INPUT.copy(),
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_PIN_INPUT.copy(),
)
assert mock_motionmount_config_flow.authenticate.called
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backoff"
freezer.tick(timedelta(seconds=2))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Now simulate the user entered the correct pin to finalize the test
type(mock_motionmount_config_flow).is_authenticated = PropertyMock(
return_value=True
)
type(mock_motionmount_config_flow).can_authenticate = PropertyMock(
return_value=True
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_PIN_INPUT.copy(),
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == ZEROCONF_NAME
assert result["data"]
assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_PORT] == PORT
assert result["result"]
assert result["result"].unique_id == ZEROCONF_MAC
async def test_authentication_multiple_incorrect_pins(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_motionmount_config_flow: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that authentication is requested when needed."""
type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME)
type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC)
type(mock_motionmount_config_flow).is_authenticated = PropertyMock(
return_value=False
)
type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1)
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=user_input,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_PIN_INPUT.copy(),
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backoff"
freezer.tick(timedelta(seconds=2))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Now simulate the user entered the correct pin to finalize the test
type(mock_motionmount_config_flow).is_authenticated = PropertyMock(
return_value=True
)
type(mock_motionmount_config_flow).can_authenticate = PropertyMock(
return_value=True
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_PIN_INPUT.copy(),
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == ZEROCONF_NAME
assert result["data"]
assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_PORT] == PORT
assert result["result"]
assert result["result"].unique_id == ZEROCONF_MAC
async def test_authentication_show_backoff_when_still_running(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_motionmount_config_flow: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that authentication is requested when needed."""
type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME)
type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC)
type(mock_motionmount_config_flow).is_authenticated = PropertyMock(
return_value=False
)
type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=MOCK_USER_INPUT.copy(),
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_PIN_INPUT.copy(),
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backoff"
# This situation happens when the user cancels the progress dialog and tries to
# configure the MotionMount again
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=None,
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backoff"
freezer.tick(timedelta(seconds=2))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Now simulate the user entered the correct pin to finalize the test
type(mock_motionmount_config_flow).is_authenticated = PropertyMock(
return_value=True
)
type(mock_motionmount_config_flow).can_authenticate = PropertyMock(
return_value=True
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_PIN_INPUT.copy(),
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == ZEROCONF_NAME
assert result["data"]
assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_PORT] == PORT
assert result["result"]
assert result["result"].unique_id == ZEROCONF_MAC
async def test_authentication_correct_pin(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that authentication is requested when needed."""
type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME)
type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC)
type(mock_motionmount_config_flow).is_authenticated = PropertyMock(
return_value=False
)
type(mock_motionmount_config_flow).can_authenticate = PropertyMock(
return_value=True
)
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=user_input,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
type(mock_motionmount_config_flow).is_authenticated = PropertyMock(
return_value=True
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_PIN_INPUT.copy(),
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == ZEROCONF_NAME
assert result["data"]
assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_PORT] == PORT
assert result["result"]
assert result["result"].unique_id == ZEROCONF_MAC
async def test_full_user_flow_implementation(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
@ -459,7 +775,7 @@ async def test_full_zeroconf_flow_implementation(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test the full manual user flow from start to finish."""
"""Test the full zeroconf flow from start to finish."""
type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME)
type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC)
@ -487,3 +803,37 @@ async def test_full_zeroconf_flow_implementation(
assert result["result"]
assert result["result"].unique_id == ZEROCONF_MAC
async def test_full_reauth_flow_implementation(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test reauthentication."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
type(mock_motionmount_config_flow).can_authenticate = PropertyMock(
return_value=True
)
type(mock_motionmount_config_flow).is_authenticated = PropertyMock(
return_value=True
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_PIN_INPUT.copy(),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"