Add integration for Vogel's MotionMount (#103498)

* Skeleton for Vogel's MotionMount support.

* Generated updates.

* Add validation of the discovered information.

* Add manual configuration

* Use a mac address as a unique id

* Add tests for config_flow

* Add a 'turn' sensor entity.

* Add all needed sensors.

* Add number and select entity for control of MotionMount

* Update based on development checklist

* Preset selector now updates when a preset is chosen

* Fix adding presets selector to device

* Remove irrelevant TODO

* Bump python-MotionMount requirement

* Invert direction of turn slider

* Prepare for PR

* Make sure entities have correct values when created

* Use device's mac address as unique id for entities.

* Fix missing files in .coveragerc

* Remove typing ignore from device library.

Improved typing also gave rise to the need to improve the callback mechanism

* Improve typing

* Convert property to shorthand form

* Remove unneeded CONF_NAME in ConfigEntry

* Add small comment

* Refresh coordinator on notification from MotionMount

* Use translation for entity

* Bump python-MotionMount

* Raise `ConfigEntryNotReady` when connect fails

* Use local variable

* Improve exception handling

* Reduce duplicate code

* Make better use of constants

* Remove unneeded callback

* Remove other occurrence of unneeded callback

* Improve removal of suffix

* Catch 'getaddrinfo' exception

* Add config flow tests for invalid hostname

* Abort if device with same hostname is already configured

* Make sure we connect to a device with the same unique id as configured

* Convert function names to snake_case

* Remove unneeded commented-out code

* Use tuple

* Make us of config_entry id when mac is missing

* Prevent update of entities when nothing changed

* Don't store data in `hass.data` until we know we will proceed

* Remove coordinator

* Handle situation where mac is EMPTY_MAC

* Disable polling

* Fix failing hassfest

* Avoid calling unique-id-less discovery handler for situations where we've an unique id
This commit is contained in:
RJPoelstra 2023-12-22 12:04:58 +01:00 committed by GitHub
parent c824d06a8c
commit 2c2e6171e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1022 additions and 0 deletions

View File

@ -757,6 +757,9 @@ omit =
homeassistant/components/motion_blinds/cover.py
homeassistant/components/motion_blinds/entity.py
homeassistant/components/motion_blinds/sensor.py
homeassistant/components/motionmount/__init__.py
homeassistant/components/motionmount/entity.py
homeassistant/components/motionmount/number.py
homeassistant/components/mpd/media_player.py
homeassistant/components/mqtt_room/sensor.py
homeassistant/components/msteams/notify.py

View File

@ -238,6 +238,7 @@ homeassistant.components.modbus.*
homeassistant.components.modem_callerid.*
homeassistant.components.moon.*
homeassistant.components.mopeka.*
homeassistant.components.motionmount.*
homeassistant.components.mqtt.*
homeassistant.components.mysensors.*
homeassistant.components.nam.*

View File

@ -809,6 +809,8 @@ build.json @home-assistant/supervisor
/tests/components/motion_blinds/ @starkillerOG
/homeassistant/components/motioneye/ @dermotduffy
/tests/components/motioneye/ @dermotduffy
/homeassistant/components/motionmount/ @RJPoelstra
/tests/components/motionmount/ @RJPoelstra
/homeassistant/components/mqtt/ @emontnemery @jbouwh
/tests/components/mqtt/ @emontnemery @jbouwh
/homeassistant/components/msteams/ @peroyvind

View File

@ -0,0 +1,61 @@
"""The Vogel's MotionMount integration."""
from __future__ import annotations
import socket
import motionmount
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.device_registry import format_mac
from .const import DOMAIN, EMPTY_MAC
PLATFORMS: list[Platform] = [
Platform.NUMBER,
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Vogel's MotionMount from a config entry."""
host = entry.data[CONF_HOST]
# Create API instance
mm = motionmount.MotionMount(host, entry.data[CONF_PORT])
# Validate the API connection
try:
await mm.connect()
except (ConnectionError, TimeoutError, socket.gaierror) as ex:
raise ConfigEntryNotReady(f"Failed to connect to {host}") from ex
found_mac = format_mac(mm.mac.hex())
if found_mac not in (EMPTY_MAC, entry.unique_id):
# If the mac address of the device does not match the unique_id
# of the config entry, it likely means the DHCP lease has expired
# and the device has been assigned a new IP address. We need to
# wait for the next discovery to find the device at its new address
# and update the config entry so we do not mix up devices.
await mm.disconnect()
raise ConfigEntryNotReady(
f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}"
)
# Store an API object for your platforms to access
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = mm
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
mm: motionmount.MotionMount = hass.data[DOMAIN].pop(entry.entry_id)
await mm.disconnect()
return unload_ok

View File

@ -0,0 +1,176 @@
"""Config flow for Vogel's MotionMount."""
import logging
import socket
from typing import Any
import motionmount
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import zeroconf
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_UUID
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.device_registry import format_mac
from .const import DOMAIN, EMPTY_MAC
_LOGGER = logging.getLogger(__name__)
# A MotionMount can be in four states:
# 1. Old CE and old Pro FW -> It doesn't supply any kind of mac
# 2. Old CE but new Pro FW -> It supplies its mac using DNS-SD, but a read of the mac fails
# 3. New CE but old Pro FW -> It doesn't supply the mac using DNS-SD but we can read it (returning the EMPTY_MAC)
# 4. New CE and new Pro FW -> Both DNS-SD and a read gives us the mac
# If we can't get the mac, we use DEFAULT_DISCOVERY_UNIQUE_ID as an ID, so we can always configure a single MotionMount. Most households will only have a single MotionMount
class MotionMountFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a Vogel's MotionMount config flow."""
VERSION = 1
def __init__(self) -> None:
"""Set up the instance."""
self.discovery_info: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initiated by the user."""
if user_input is None:
return self._show_setup_form()
info = {}
try:
info = await self._validate_input(user_input)
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] = config_entries.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:
unique_id = info[CONF_UUID]
else:
unique_id = config_entries.DEFAULT_DISCOVERY_UNIQUE_ID
name = info.get(CONF_NAME, user_input[CONF_HOST])
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],
}
)
return self.async_create_entry(title=name, data=user_input)
async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> FlowResult:
"""Handle zeroconf discovery."""
# Extract information from discovery
host = discovery_info.hostname
port = discovery_info.port
zctype = discovery_info.type
name = discovery_info.name.removesuffix(f".{zctype}")
unique_id = discovery_info.properties.get("mac")
self.discovery_info.update(
{
CONF_HOST: host,
CONF_PORT: port,
CONF_NAME: name,
}
)
if unique_id:
# If we already have the unique id, try to set it now
# so we can avoid probing the device if its already
# configured or ignored
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured(
updates={CONF_HOST: host, CONF_PORT: port}
)
else:
# Avoid probing devices that already have an entry
self._async_abort_entries_match({CONF_HOST: host})
self.context.update({"title_placeholders": {"name": name}})
try:
info = await self._validate_input(self.discovery_info)
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:
unique_id = info[CONF_UUID]
if unique_id:
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured(
updates={CONF_HOST: host, CONF_PORT: port}
)
else:
await self._async_handle_discovery_without_unique_id()
return await self.async_step_zeroconf_confirm()
async def async_step_zeroconf_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a confirmation flow initiated by zeroconf."""
if user_input is None:
return self.async_show_form(
step_id="zeroconf_confirm",
description_placeholders={CONF_NAME: self.discovery_info[CONF_NAME]},
errors={},
)
return self.async_create_entry(
title=self.discovery_info[CONF_NAME],
data=self.discovery_info,
)
async def _validate_input(self, data: dict) -> dict[str, Any]:
"""Validate the user input allows us to connect."""
mm = motionmount.MotionMount(data[CONF_HOST], data[CONF_PORT])
try:
await mm.connect()
finally:
await mm.disconnect()
return {CONF_UUID: format_mac(mm.mac.hex()), CONF_NAME: mm.name}
def _show_setup_form(self, errors: dict[str, str] | None = None) -> FlowResult:
"""Show the setup form to the user."""
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=23): int,
}
),
errors=errors or {},
)

View File

@ -0,0 +1,5 @@
"""Constants for the Vogel's MotionMount integration."""
DOMAIN = "motionmount"
EMPTY_MAC = "00:00:00:00:00:00"

View File

@ -0,0 +1,53 @@
"""Support for MotionMount sensors."""
import motionmount
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
from homeassistant.helpers.entity import Entity
from .const import DOMAIN, EMPTY_MAC
class MotionMountEntity(Entity):
"""Representation of a MotionMount entity."""
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None:
"""Initialize general MotionMount entity."""
self.mm = mm
mac = format_mac(mm.mac.hex())
# Create a base unique id
if mac == EMPTY_MAC:
self._base_unique_id = config_entry.entry_id
else:
self._base_unique_id = mac
# Set device info
self._attr_device_info = DeviceInfo(
name=mm.name,
manufacturer="Vogel's",
model="TVM 7675",
)
if mac == EMPTY_MAC:
self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, config_entry.entry_id)}
else:
self._attr_device_info[ATTR_CONNECTIONS] = {
(dr.CONNECTION_NETWORK_MAC, mac)
}
async def async_added_to_hass(self) -> None:
"""Store register state change callback."""
self.mm.add_listener(self.async_write_ha_state)
await super().async_added_to_hass()
async def async_will_remove_from_hass(self) -> None:
"""Remove register state change callback."""
self.mm.remove_listener(self.async_write_ha_state)
await super().async_will_remove_from_hass()

View File

@ -0,0 +1,11 @@
{
"domain": "motionmount",
"name": "Vogel's MotionMount",
"codeowners": ["@RJPoelstra"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/motionmount",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["python-MotionMount==0.3.1"],
"zeroconf": ["_tvm._tcp.local."]
}

View File

@ -0,0 +1,71 @@
"""Support for MotionMount numeric control."""
import motionmount
from homeassistant.components.number import NumberEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .entity import MotionMountEntity
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up Vogel's MotionMount from a config entry."""
mm: motionmount.MotionMount = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
(
MotionMountExtension(mm, entry),
MotionMountTurn(mm, entry),
)
)
class MotionMountExtension(MotionMountEntity, NumberEntity):
"""The target extension position of a MotionMount."""
_attr_native_max_value = 100
_attr_native_min_value = 0
_attr_native_unit_of_measurement = PERCENTAGE
_attr_translation_key = "motionmount_extension"
def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None:
"""Initialize Extension number."""
super().__init__(mm, config_entry)
self._attr_unique_id = f"{self._base_unique_id}-extension"
@property
def native_value(self) -> float:
"""Get native value."""
return float(self.mm.extension or 0)
async def async_set_native_value(self, value: float) -> None:
"""Set the new value for extension."""
await self.mm.set_extension(int(value))
class MotionMountTurn(MotionMountEntity, NumberEntity):
"""The target turn position of a MotionMount."""
_attr_native_max_value = 100
_attr_native_min_value = -100
_attr_native_unit_of_measurement = PERCENTAGE
_attr_translation_key = "motionmount_turn"
def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None:
"""Initialize Turn number."""
super().__init__(mm, config_entry)
self._attr_unique_id = f"{self._base_unique_id}-turn"
@property
def native_value(self) -> float:
"""Get native value."""
return float(self.mm.turn or 0) * -1
async def async_set_native_value(self, value: float) -> None:
"""Set the new value for turn."""
await self.mm.set_turn(int(value * -1))

View File

@ -0,0 +1,37 @@
{
"config": {
"flow_title": "{name}",
"step": {
"user": {
"title": "Link your MotionMount",
"description": "Set up your MotionMount to integrate with Home Assistant.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
}
},
"zeroconf_confirm": {
"description": "Do you want to set up {name}?",
"title": "Discovered MotionMount"
}
},
"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.",
"not_connected": "Failed to connect.",
"invalid_response": "Failed to connect due to an invalid response from the MotionMount."
}
},
"entity": {
"number": {
"motionmount_extension": {
"name": "Extension"
},
"motionmount_turn": {
"name": "Turn"
}
}
}
}

View File

@ -304,6 +304,7 @@ FLOWS = {
"mopeka",
"motion_blinds",
"motioneye",
"motionmount",
"mqtt",
"mullvad",
"mutesync",

View File

@ -3613,6 +3613,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
"motionmount": {
"name": "Vogel's MotionMount",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_push"
},
"mpd": {
"name": "Music Player Daemon (MPD)",
"integration_type": "hub",

View File

@ -705,6 +705,11 @@ ZEROCONF = {
"domain": "apple_tv",
},
],
"_tvm._tcp.local.": [
{
"domain": "motionmount",
},
],
"_uzg-01._tcp.local.": [
{
"domain": "zha",

View File

@ -2141,6 +2141,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.motionmount.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.mqtt.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -2132,6 +2132,9 @@ pytfiac==0.4
# homeassistant.components.thinkingcleaner
pythinkingcleaner==0.0.3
# homeassistant.components.motionmount
python-MotionMount==0.3.1
# homeassistant.components.awair
python-awair==0.2.4

View File

@ -1622,6 +1622,9 @@ pytankerkoenig==0.0.6
# homeassistant.components.tautulli
pytautulli==23.1.1
# homeassistant.components.motionmount
python-MotionMount==0.3.1
# homeassistant.components.awair
python-awair==0.2.4

View File

@ -0,0 +1,42 @@
"""Tests for the Vogel's MotionMount integration."""
from ipaddress import ip_address
from homeassistant.components import zeroconf
from homeassistant.const import CONF_HOST, CONF_PORT
HOST = "192.168.1.31"
PORT = 23
TVM_ZEROCONF_SERVICE_TYPE = "_tvm._tcp.local."
ZEROCONF_NAME = "My MotionMount"
ZEROCONF_HOST = HOST
ZEROCONF_HOSTNAME = "MMF8A55F.local."
ZEROCONF_PORT = PORT
ZEROCONF_MAC = "c4:dd:57:f8:a5:5f"
MOCK_USER_INPUT = {
CONF_HOST: HOST,
CONF_PORT: PORT,
}
MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = zeroconf.ZeroconfServiceInfo(
type=TVM_ZEROCONF_SERVICE_TYPE,
name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}",
ip_address=ip_address(ZEROCONF_HOST),
ip_addresses=[ip_address(ZEROCONF_HOST)],
hostname=ZEROCONF_HOSTNAME,
port=ZEROCONF_PORT,
properties={"txtvers": "1", "model": "TVM 7675"},
)
MOCK_ZEROCONF_TVM_SERVICE_INFO_V2 = zeroconf.ZeroconfServiceInfo(
type=TVM_ZEROCONF_SERVICE_TYPE,
name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}",
ip_address=ip_address(ZEROCONF_HOST),
ip_addresses=[ip_address(ZEROCONF_HOST)],
hostname=ZEROCONF_HOSTNAME,
port=ZEROCONF_PORT,
properties={"mac": ZEROCONF_MAC, "txtvers": "2", "model": "TVM 7675"},
)

View File

@ -0,0 +1,44 @@
"""Fixtures for Vogel's MotionMount integration tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.motionmount.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PORT
from . import HOST, PORT, ZEROCONF_MAC, ZEROCONF_NAME
from tests.common import MockConfigEntry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title=ZEROCONF_NAME,
domain=DOMAIN,
data={CONF_HOST: HOST, CONF_PORT: PORT},
unique_id=ZEROCONF_MAC,
)
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Mock setting up a config entry."""
with patch(
"homeassistant.components.motionmount.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_motionmount_config_flow() -> Generator[None, MagicMock, None]:
"""Return a mocked MotionMount config flow."""
with patch(
"homeassistant.components.motionmount.config_flow.motionmount.MotionMount",
autospec=True,
) as motionmount_mock:
client = motionmount_mock.return_value
yield client

View File

@ -0,0 +1,488 @@
"""Tests for the Vogel's MotionMount config flow."""
import dataclasses
import socket
from unittest.mock import MagicMock, PropertyMock
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.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import (
HOST,
MOCK_USER_INPUT,
MOCK_ZEROCONF_TVM_SERVICE_INFO_V1,
MOCK_ZEROCONF_TVM_SERVICE_INFO_V2,
PORT,
ZEROCONF_HOSTNAME,
ZEROCONF_MAC,
ZEROCONF_NAME,
)
from tests.common import MockConfigEntry
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"] == FlowResultType.FORM
async def test_user_connection_error(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that the flow is aborted when there is an connection error."""
mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError()
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"] == FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
async def test_user_connection_error_invalid_hostname(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that the flow is aborted when an invalid hostname is provided."""
mock_motionmount_config_flow.connect.side_effect = socket.gaierror()
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"] == FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
async def test_user_timeout_error(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that the flow is aborted when there is a timeout error."""
mock_motionmount_config_flow.connect.side_effect = TimeoutError()
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"] == FlowResultType.ABORT
assert result["reason"] == "time_out"
async def test_user_not_connected_error(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that the flow is aborted when there is a not connected error."""
mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError()
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"] == FlowResultType.ABORT
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"] == 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,
) -> None:
"""Test that the flow creates an entry when there is a response error."""
type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME)
type(mock_motionmount_config_flow).mac = PropertyMock(
return_value=b"\x00\x00\x00\x00\x00\x00"
)
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"] == 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"]
async def test_user_response_error_single_device_new_ce_new_pro(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that the flow creates an entry when there is a response error."""
type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME)
type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC)
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"] == 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_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"] == 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,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that the flow is aborted when there are multiple devices."""
mock_config_entry.add_to_hass(hass)
type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME)
type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC)
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"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_zeroconf_connection_error(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that the flow is aborted when there is an connection error."""
mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError()
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["type"] == FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
async def test_zeroconf_connection_error_invalid_hostname(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that the flow is aborted when there is an connection error."""
mock_motionmount_config_flow.connect.side_effect = socket.gaierror()
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["type"] == FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
async def test_zeroconf_timout_error(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that the flow is aborted when there is a timeout error."""
mock_motionmount_config_flow.connect.side_effect = TimeoutError()
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["type"] == FlowResultType.ABORT
assert result["reason"] == "time_out"
async def test_zeroconf_not_connected_error(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that the flow is aborted when there is a not connected error."""
mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError()
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["type"] == FlowResultType.ABORT
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"] == 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"] == 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,
) -> None:
"""Test that the zeroconf confirmation form is served."""
type(mock_motionmount_config_flow).mac = PropertyMock(
return_value=b"\x00\x00\x00\x00\x00\x00"
)
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"] == FlowResultType.FORM
assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"}
async def test_show_zeroconf_form_new_ce_new_pro(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test that the zeroconf confirmation form is served."""
type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC)
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"] == FlowResultType.FORM
assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"}
async def test_zeroconf_device_exists_abort(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test we abort zeroconf flow if device already configured."""
mock_config_entry.add_to_hass(hass)
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"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_full_user_flow_implementation(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test the full manual user 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)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["step_id"] == "user"
assert result["type"] == FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_USER_INPUT.copy(),
)
assert result["type"] == 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_zeroconf_flow_implementation(
hass: HomeAssistant,
mock_motionmount_config_flow: MagicMock,
) -> None:
"""Test the full manual user 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)
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"] == FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["type"] == 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