Modern Forms integration initial pass - Fan (#51317)

* Modern Forms integration initial pass

* cleanup of typing and nits

* Stripped PR down to Fan only

* Review cleanup

* Set sleep_time to be required for service

* Adjust minimum sleep time to one minute.

* Code review changes

* cleanup icon init a little
This commit is contained in:
Brian Towles 2021-06-08 01:22:50 -05:00 committed by GitHub
parent 51fa28aac3
commit 01d4140177
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1217 additions and 0 deletions

View File

@ -300,6 +300,7 @@ homeassistant/components/minecraft_server/* @elmurato
homeassistant/components/minio/* @tkislan
homeassistant/components/mobile_app/* @robbiet480
homeassistant/components/modbus/* @adamchengtkc @janiversen @vzahradnik
homeassistant/components/modern_forms/* @wonderslug
homeassistant/components/monoprice/* @etsinko @OnFreund
homeassistant/components/moon/* @fabaff
homeassistant/components/motion_blinds/* @starkillerOG

View File

@ -0,0 +1,176 @@
"""The Modern Forms integration."""
from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
from aiomodernforms import (
ModernFormsConnectionError,
ModernFormsDevice,
ModernFormsError,
)
from aiomodernforms.models import Device as ModernFormsDeviceState
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_MODEL, ATTR_NAME, ATTR_SW_VERSION, CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from .const import ATTR_IDENTIFIERS, ATTR_MANUFACTURER, DOMAIN
SCAN_INTERVAL = timedelta(seconds=5)
PLATFORMS = [
FAN_DOMAIN,
]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a Modern Forms device from a config entry."""
# Create Modern Forms instance for this entry
coordinator = ModernFormsDataUpdateCoordinator(hass, host=entry.data[CONF_HOST])
await coordinator.async_refresh()
if not coordinator.last_update_success:
raise ConfigEntryNotReady
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
if entry.unique_id is None:
hass.config_entries.async_update_entry(
entry, unique_id=coordinator.data.info.mac_address
)
# Set up all platforms for this device/entry.
for platform in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, platform)
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Modern Forms config entry."""
# Unload entities for this entry/device.
unload_ok = all(
await asyncio.gather(
*(
hass.config_entries.async_forward_entry_unload(entry, platform)
for platform in PLATFORMS
)
)
)
if unload_ok:
del hass.data[DOMAIN][entry.entry_id]
if not hass.data[DOMAIN]:
del hass.data[DOMAIN]
return unload_ok
def modernforms_exception_handler(func):
"""Decorate Modern Forms calls to handle Modern Forms exceptions.
A decorator that wraps the passed in function, catches Modern Forms errors,
and handles the availability of the device in the data coordinator.
"""
async def handler(self, *args, **kwargs):
try:
await func(self, *args, **kwargs)
self.coordinator.update_listeners()
except ModernFormsConnectionError as error:
_LOGGER.error("Error communicating with API: %s", error)
self.coordinator.last_update_success = False
self.coordinator.update_listeners()
except ModernFormsError as error:
_LOGGER.error("Invalid response from API: %s", error)
return handler
class ModernFormsDataUpdateCoordinator(DataUpdateCoordinator[ModernFormsDeviceState]):
"""Class to manage fetching Modern Forms data from single endpoint."""
def __init__(
self,
hass: HomeAssistant,
*,
host: str,
) -> None:
"""Initialize global Modern Forms data updater."""
self.modernforms = ModernFormsDevice(
host, session=async_get_clientsession(hass)
)
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
def update_listeners(self) -> None:
"""Call update on all listeners."""
for update_callback in self._listeners:
update_callback()
async def _async_update_data(self) -> ModernFormsDevice:
"""Fetch data from Modern Forms."""
try:
return await self.modernforms.update(
full_update=not self.last_update_success
)
except ModernFormsError as error:
raise UpdateFailed(f"Invalid response from API: {error}") from error
class ModernFormsDeviceEntity(CoordinatorEntity[ModernFormsDataUpdateCoordinator]):
"""Defines a Modern Forms device entity."""
coordinator: ModernFormsDataUpdateCoordinator
def __init__(
self,
*,
entry_id: str,
coordinator: ModernFormsDataUpdateCoordinator,
name: str,
icon: str | None = None,
enabled_default: bool = True,
) -> None:
"""Initialize the Modern Forms entity."""
super().__init__(coordinator)
self._attr_enabled_default = enabled_default
self._entry_id = entry_id
self._attr_icon = icon
self._attr_name = name
self._unsub_dispatcher = None
@property
def device_info(self) -> DeviceInfo:
"""Return device information about this Modern Forms device."""
return {
ATTR_IDENTIFIERS: {(DOMAIN, self.coordinator.data.info.mac_address)}, # type: ignore
ATTR_NAME: self.coordinator.data.info.device_name,
ATTR_MANUFACTURER: "Modern Forms",
ATTR_MODEL: self.coordinator.data.info.fan_type,
ATTR_SW_VERSION: f"{self.coordinator.data.info.firmware_version} / {self.coordinator.data.info.main_mcu_firmware_version}",
}

View File

@ -0,0 +1,120 @@
"""Config flow for Modern Forms."""
from __future__ import annotations
from typing import Any
from aiomodernforms import ModernFormsConnectionError, ModernFormsDevice
import voluptuous as vol
from homeassistant.config_entries import (
CONN_CLASS_LOCAL_POLL,
SOURCE_ZEROCONF,
ConfigFlow,
)
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import DiscoveryInfoType
from .const import DOMAIN
class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a ModernForms config flow."""
VERSION = 1
CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle setup by user for Modern Forms integration."""
return await self._handle_config_flow(user_input)
async def async_step_zeroconf(
self, discovery_info: DiscoveryInfoType
) -> FlowResult:
"""Handle zeroconf discovery."""
host = discovery_info["hostname"].rstrip(".")
name, _ = host.rsplit(".")
self.context.update(
{
CONF_HOST: discovery_info["host"],
CONF_NAME: name,
CONF_MAC: discovery_info["properties"].get(CONF_MAC),
"title_placeholders": {"name": name},
}
)
# Prepare configuration flow
return await self._handle_config_flow(discovery_info, True)
async def async_step_zeroconf_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initiated by zeroconf."""
return await self._handle_config_flow(user_input)
async def _handle_config_flow(
self, user_input: dict[str, Any] | None = None, prepare: bool = False
) -> FlowResult:
"""Config flow handler for ModernForms."""
source = self.context.get("source")
# Request user input, unless we are preparing discovery flow
if user_input is None:
user_input = {}
if not prepare:
if source == SOURCE_ZEROCONF:
return self._show_confirm_dialog()
return self._show_setup_form()
if source == SOURCE_ZEROCONF:
user_input[CONF_HOST] = self.context.get(CONF_HOST)
user_input[CONF_MAC] = self.context.get(CONF_MAC)
if user_input.get(CONF_MAC) is None or not prepare:
session = async_get_clientsession(self.hass)
device = ModernFormsDevice(user_input[CONF_HOST], session=session)
try:
device = await device.update()
except ModernFormsConnectionError:
if source == SOURCE_ZEROCONF:
return self.async_abort(reason="cannot_connect")
return self._show_setup_form({"base": "cannot_connect"})
user_input[CONF_MAC] = device.info.mac_address
user_input[CONF_NAME] = device.info.device_name
# Check if already configured
await self.async_set_unique_id(user_input[CONF_MAC])
self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]})
title = device.info.device_name
if source == SOURCE_ZEROCONF:
title = self.context.get(CONF_NAME)
if prepare:
return await self.async_step_zeroconf_confirm()
return self.async_create_entry(
title=title,
data={CONF_HOST: user_input[CONF_HOST], CONF_MAC: user_input[CONF_MAC]},
)
def _show_setup_form(self, errors: dict | 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}),
errors=errors or {},
)
def _show_confirm_dialog(self, errors: dict | None = None) -> FlowResult:
"""Show the confirm dialog to the user."""
name = self.context.get(CONF_NAME)
return self.async_show_form(
step_id="zeroconf_confirm",
description_placeholders={"name": name},
errors=errors or {},
)

View File

@ -0,0 +1,30 @@
"""Constants for the Modern Forms integration."""
DOMAIN = "modern_forms"
ATTR_IDENTIFIERS = "identifiers"
ATTR_MANUFACTURER = "manufacturer"
ATTR_MODEL = "model"
ATTR_OWNER = "owner"
ATTR_IDENTITY = "identity"
ATTR_MCU_FIRMWARE_VERSION = "mcu_firmware_version"
ATTR_FIRMWARE_VERSION = "firmware_version"
SIGNAL_INSTANCE_ADD = f"{DOMAIN}_instance_add_signal." "{}"
SIGNAL_INSTANCE_REMOVE = f"{DOMAIN}_instance_remove_signal." "{}"
SIGNAL_ENTITY_REMOVE = f"{DOMAIN}_entity_remove_signal." "{}"
CONF_ON_UNLOAD = "ON_UNLOAD"
OPT_BRIGHTNESS = "brightness"
OPT_ON = "on"
OPT_SPEED = "speed"
# Services
SERVICE_SET_LIGHT_SLEEP_TIMER = "set_light_sleep_timer"
SERVICE_CLEAR_LIGHT_SLEEP_TIMER = "clear_light_sleep_timer"
SERVICE_SET_FAN_SLEEP_TIMER = "set_fan_sleep_timer"
SERVICE_CLEAR_FAN_SLEEP_TIMER = "clear_fan_sleep_timer"
ATTR_SLEEP_TIME = "sleep_time"
CLEAR_TIMER = 0

View File

@ -0,0 +1,180 @@
"""Support for Modern Forms Fan Fans."""
from __future__ import annotations
from functools import partial
from typing import Any, Callable
from aiomodernforms.const import FAN_POWER_OFF, FAN_POWER_ON
import voluptuous as vol
from homeassistant.components.fan import SUPPORT_DIRECTION, SUPPORT_SET_SPEED, FanEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import callback
import homeassistant.helpers.entity_platform as entity_platform
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util.percentage import (
int_states_in_range,
percentage_to_ranged_value,
ranged_value_to_percentage,
)
from . import (
ModernFormsDataUpdateCoordinator,
ModernFormsDeviceEntity,
modernforms_exception_handler,
)
from .const import (
ATTR_SLEEP_TIME,
CLEAR_TIMER,
DOMAIN,
OPT_ON,
OPT_SPEED,
SERVICE_CLEAR_FAN_SLEEP_TIMER,
SERVICE_SET_FAN_SLEEP_TIMER,
)
async def async_setup_entry(
hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable
) -> None:
"""Set up a Modern Forms platform from config entry."""
coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_SET_FAN_SLEEP_TIMER,
{
vol.Required(ATTR_SLEEP_TIME): vol.All(
vol.Coerce(int), vol.Range(min=1, max=1440)
),
},
"async_set_fan_sleep_timer",
)
platform.async_register_entity_service(
SERVICE_CLEAR_FAN_SLEEP_TIMER,
{},
"async_clear_fan_sleep_timer",
)
update_func = partial(
async_update_fan, config_entry, coordinator, {}, async_add_entities
)
coordinator.async_add_listener(update_func)
update_func()
class ModernFormsFanEntity(FanEntity, ModernFormsDeviceEntity):
"""Defines a Modern Forms light."""
SPEED_RANGE = (1, 6) # off is not included
def __init__(
self, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator
) -> None:
"""Initialize Modern Forms light."""
super().__init__(
entry_id=entry_id,
coordinator=coordinator,
name=f"{coordinator.data.info.device_name} Fan",
)
self._attr_unique_id = f"{self.coordinator.data.info.mac_address}_fan"
@property
def supported_features(self) -> int:
"""Flag supported features."""
return SUPPORT_DIRECTION | SUPPORT_SET_SPEED
@property
def percentage(self) -> int | None:
"""Return the current speed percentage."""
percentage = 0
if bool(self.coordinator.data.state.fan_on):
percentage = ranged_value_to_percentage(
self.SPEED_RANGE, self.coordinator.data.state.fan_speed
)
return percentage
@property
def current_direction(self) -> str:
"""Return the current direction of the fan."""
return self.coordinator.data.state.fan_direction
@property
def speed_count(self) -> int:
"""Return the number of speeds the fan supports."""
return int_states_in_range(self.SPEED_RANGE)
@property
def is_on(self) -> bool:
"""Return the state of the fan."""
return bool(self.coordinator.data.state.fan_on)
@modernforms_exception_handler
async def async_set_direction(self, direction: str) -> None:
"""Set the direction of the fan."""
await self.coordinator.modernforms.fan(direction=direction)
@modernforms_exception_handler
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
if percentage > 0:
await self.async_turn_on(percentage=percentage)
else:
await self.async_turn_off()
@modernforms_exception_handler
async def async_turn_on(
self,
speed: int | None = None,
percentage: int | None = None,
preset_mode: int | None = None,
**kwargs: Any,
) -> None:
"""Turn on the fan."""
data = {OPT_ON: FAN_POWER_ON}
if percentage:
data[OPT_SPEED] = round(
percentage_to_ranged_value(self.SPEED_RANGE, percentage)
)
await self.coordinator.modernforms.fan(**data)
@modernforms_exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off."""
await self.coordinator.modernforms.fan(on=FAN_POWER_OFF)
@modernforms_exception_handler
async def async_set_fan_sleep_timer(
self,
sleep_time: int,
) -> None:
"""Set a Modern Forms light sleep timer."""
await self.coordinator.modernforms.fan(sleep=sleep_time * 60)
@modernforms_exception_handler
async def async_clear_fan_sleep_timer(
self,
) -> None:
"""Clear a Modern Forms fan sleep timer."""
await self.coordinator.modernforms.fan(sleep=CLEAR_TIMER)
@callback
def async_update_fan(
entry: ConfigEntry,
coordinator: ModernFormsDataUpdateCoordinator,
current: dict[str, ModernFormsFanEntity],
async_add_entities,
) -> None:
"""Update Modern Forms Fan info."""
if not current:
current[entry.entry_id] = ModernFormsFanEntity(
entry_id=entry.entry_id, coordinator=coordinator
)
async_add_entities([current[entry.entry_id]])

View File

@ -0,0 +1,17 @@
{
"domain": "modern_forms",
"name": "Modern Forms",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/modern_forms",
"requirements": [
"aiomodernforms==0.1.5"
],
"zeroconf": [
{"type":"_easylink._tcp.local.", "name":"wac*"}
],
"dependencies": [],
"codeowners": [
"@wonderslug"
],
"iot_class": "local_polling"
}

View File

@ -0,0 +1,28 @@
set_fan_sleep_timer:
name: Set fan sleep timer
description: Set a sleep timer on a Modern Forms fan.
target:
entity:
integration: modern_forms
domain: fan
fields:
sleep_time:
name: Sleep Time
description: Number of seconds to set the timer.
required: true
example: "900"
selector:
number:
min: 1
max: 1440
step: 1
unit_of_measurement: minutes
mode: slider
clear_fan_sleep_timer:
name: Clear fan sleep timer
description: Clear the sleep timer on a Modern Forms fan.
target:
entity:
integration: modern_forms
domain: fan

View File

@ -0,0 +1,28 @@
{
"title": "Modern Forms",
"config": {
"flow_title": "{name}",
"step": {
"user": {
"description": "Set up your Modern Forms fan to integrate with Home Assistant.",
"data": {
"host": "[%key:common::config_flow::data::host%]"
}
},
"confirm": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
},
"zeroconf_confirm": {
"description": "Do you want to add the Modern Forms fan named `{name}` to Home Assistant?",
"title": "Discovered Modern Forms fan device"
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
}
}
}

View File

@ -0,0 +1,28 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured",
"cannot_connect": "Failed to connect"
},
"error": {
"cannot_connect": "Failed to connect"
},
"flow_title": "{name}",
"step": {
"confirm": {
"description": "Do you want to start set up?"
},
"user": {
"data": {
"host": "Host"
},
"description": "Set up your Modern Forms fan to integrate with Home Assistant."
},
"zeroconf_confirm": {
"description": "Do you want to add the Modern Forms fan named `{name}` to Home Assistant?",
"title": "Discovered Modern Forms fan device"
}
}
},
"title": "Modern Forms"
}

View File

@ -156,6 +156,7 @@ FLOWS = [
"mill",
"minecraft_server",
"mobile_app",
"modern_forms",
"monoprice",
"motion_blinds",
"motioneye",

View File

@ -60,6 +60,12 @@ ZEROCONF = {
"domain": "devolo_home_control"
}
],
"_easylink._tcp.local.": [
{
"domain": "modern_forms",
"name": "wac*"
}
],
"_elg._tcp.local.": [
{
"domain": "elgato"

View File

@ -205,6 +205,9 @@ aiolip==1.1.4
# homeassistant.components.lyric
aiolyric==1.0.7
# homeassistant.components.modern_forms
aiomodernforms==0.1.5
# homeassistant.components.keyboard_remote
aionotify==0.2.0

View File

@ -130,6 +130,9 @@ aiolip==1.1.4
# homeassistant.components.lyric
aiolyric==1.0.7
# homeassistant.components.modern_forms
aiomodernforms==0.1.5
# homeassistant.components.notion
aionotion==1.1.0

View File

@ -0,0 +1,65 @@
"""Tests for the Modern Forms integration."""
import json
from typing import Callable
from aiomodernforms.const import COMMAND_QUERY_STATIC_DATA
from homeassistant.components.modern_forms.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_MAC, CONTENT_TYPE_JSON
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse
async def modern_forms_call_mock(method, url, data):
"""Set up the basic returns based on info or status request."""
if COMMAND_QUERY_STATIC_DATA in data:
fixture = "modern_forms/device_info.json"
else:
fixture = "modern_forms/device_status.json"
response = AiohttpClientMockResponse(
method=method, url=url, json=json.loads(load_fixture(fixture))
)
return response
async def modern_forms_no_light_call_mock(method, url, data):
"""Set up the basic returns based on info or status request."""
if COMMAND_QUERY_STATIC_DATA in data:
fixture = "modern_forms/device_info_no_light.json"
else:
fixture = "modern_forms/device_status_no_light.json"
response = AiohttpClientMockResponse(
method=method, url=url, json=json.loads(load_fixture(fixture))
)
return response
async def init_integration(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
rgbw: bool = False,
skip_setup: bool = False,
mock_type: Callable = modern_forms_call_mock,
) -> MockConfigEntry:
"""Set up the Modern Forms integration in Home Assistant."""
aioclient_mock.post(
"http://192.168.1.123:80/mf",
side_effect=mock_type,
headers={"Content-Type": CONTENT_TYPE_JSON},
)
entry = MockConfigEntry(
domain=DOMAIN, data={CONF_HOST: "192.168.1.123", CONF_MAC: "AA:BB:CC:DD:EE:FF"}
)
entry.add_to_hass(hass)
if not skip_setup:
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry

View File

@ -0,0 +1,198 @@
"""Tests for the Modern Forms config flow."""
from unittest.mock import MagicMock, patch
import aiohttp
from aiomodernforms import ModernFormsConnectionError
from homeassistant.components.modern_forms.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONTENT_TYPE_JSON
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import (
RESULT_TYPE_ABORT,
RESULT_TYPE_CREATE_ENTRY,
RESULT_TYPE_FORM,
)
from . import init_integration
from tests.common import load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
async def test_full_user_flow_implementation(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test the full manual user flow from start to finish."""
aioclient_mock.post(
"http://192.168.1.123:80/mf",
text=load_fixture("modern_forms/device_info.json"),
headers={"Content-Type": CONTENT_TYPE_JSON},
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result.get("step_id") == "user"
assert result.get("type") == RESULT_TYPE_FORM
assert "flow_id" in result
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "192.168.1.123"}
)
assert result.get("title") == "ModernFormsFan"
assert "data" in result
assert result.get("type") == RESULT_TYPE_CREATE_ENTRY
assert result["data"][CONF_HOST] == "192.168.1.123"
assert result["data"][CONF_MAC] == "AA:BB:CC:DD:EE:FF"
async def test_full_zeroconf_flow_implementation(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test the full manual user flow from start to finish."""
aioclient_mock.post(
"http://192.168.1.123:80/mf",
text=load_fixture("modern_forms/device_info.json"),
headers={"Content-Type": CONTENT_TYPE_JSON},
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data={"host": "192.168.1.123", "hostname": "example.local.", "properties": {}},
)
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert result.get("description_placeholders") == {CONF_NAME: "example"}
assert result.get("step_id") == "zeroconf_confirm"
assert result.get("type") == RESULT_TYPE_FORM
assert "flow_id" in result
flow = flows[0]
assert "context" in flow
assert flow["context"][CONF_HOST] == "192.168.1.123"
assert flow["context"][CONF_NAME] == "example"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result2.get("title") == "example"
assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY
assert "data" in result2
assert result2["data"][CONF_HOST] == "192.168.1.123"
assert result2["data"][CONF_MAC] == "AA:BB:CC:DD:EE:FF"
@patch(
"homeassistant.components.modern_forms.ModernFormsDevice.update",
side_effect=ModernFormsConnectionError,
)
async def test_connection_error(
update_mock: MagicMock, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we show user form on Modern Forms connection error."""
aioclient_mock.post("http://example.com/mf", exc=aiohttp.ClientError)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data={CONF_HOST: "example.com"},
)
assert result.get("type") == RESULT_TYPE_FORM
assert result.get("step_id") == "user"
assert result.get("errors") == {"base": "cannot_connect"}
@patch(
"homeassistant.components.modern_forms.ModernFormsDevice.update",
side_effect=ModernFormsConnectionError,
)
async def test_zeroconf_connection_error(
update_mock: MagicMock, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we abort zeroconf flow on Modern Forms connection error."""
aioclient_mock.post("http://192.168.1.123/mf", exc=aiohttp.ClientError)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data={"host": "192.168.1.123", "hostname": "example.local.", "properties": {}},
)
assert result.get("type") == RESULT_TYPE_ABORT
assert result.get("reason") == "cannot_connect"
@patch(
"homeassistant.components.modern_forms.ModernFormsDevice.update",
side_effect=ModernFormsConnectionError,
)
async def test_zeroconf_confirm_connection_error(
update_mock: MagicMock, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we abort zeroconf flow on Modern Forms connection error."""
aioclient_mock.post("http://192.168.1.123:80/mf", exc=aiohttp.ClientError)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_ZEROCONF,
CONF_HOST: "example.com",
CONF_NAME: "test",
},
data={"host": "192.168.1.123", "hostname": "example.com.", "properties": {}},
)
assert result.get("type") == RESULT_TYPE_ABORT
assert result.get("reason") == "cannot_connect"
async def test_user_device_exists_abort(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we abort zeroconf flow if Modern Forms device already configured."""
aioclient_mock.post(
"http://192.168.1.123:80/mf",
text=load_fixture("modern_forms/device_info.json"),
headers={"Content-Type": CONTENT_TYPE_JSON},
)
await init_integration(hass, aioclient_mock)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data={CONF_HOST: "192.168.1.123"},
)
assert result.get("type") == RESULT_TYPE_ABORT
assert result.get("reason") == "already_configured"
async def test_zeroconf_with_mac_device_exists_abort(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we abort zeroconf flow if a Modern Forms device already configured."""
await init_integration(hass, aioclient_mock)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data={
"host": "192.168.1.123",
"hostname": "example.local.",
"properties": {CONF_MAC: "AA:BB:CC:DD:EE:FF"},
},
)
assert result.get("type") == RESULT_TYPE_ABORT
assert result.get("reason") == "already_configured"

View File

@ -0,0 +1,213 @@
"""Tests for the Modern Forms fan platform."""
from unittest.mock import patch
from aiomodernforms import ModernFormsConnectionError
from homeassistant.components.fan import (
ATTR_DIRECTION,
ATTR_PERCENTAGE,
DIRECTION_FORWARD,
DIRECTION_REVERSE,
DOMAIN as FAN_DOMAIN,
SERVICE_SET_DIRECTION,
SERVICE_SET_PERCENTAGE,
)
from homeassistant.components.modern_forms.const import (
ATTR_SLEEP_TIME,
DOMAIN,
SERVICE_CLEAR_FAN_SLEEP_TIMER,
SERVICE_SET_FAN_SLEEP_TIMER,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_ON,
STATE_UNAVAILABLE,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.components.modern_forms import init_integration
from tests.test_util.aiohttp import AiohttpClientMocker
async def test_fan_state(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test the creation and values of the Modern Forms fans."""
await init_integration(hass, aioclient_mock)
entity_registry = er.async_get(hass)
state = hass.states.get("fan.modernformsfan_fan")
assert state
assert state.attributes.get(ATTR_PERCENTAGE) == 50
assert state.attributes.get(ATTR_DIRECTION) == DIRECTION_FORWARD
assert state.state == STATE_ON
entry = entity_registry.async_get("fan.modernformsfan_fan")
assert entry
assert entry.unique_id == "AA:BB:CC:DD:EE:FF_fan"
async def test_change_state(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog
) -> None:
"""Test the change of state of the Modern Forms fan."""
await init_integration(hass, aioclient_mock)
with patch("aiomodernforms.ModernFormsDevice.fan") as fan_mock:
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "fan.modernformsfan_fan"},
blocking=True,
)
await hass.async_block_till_done()
fan_mock.assert_called_once_with(
on=False,
)
with patch("aiomodernforms.ModernFormsDevice.fan") as fan_mock:
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: "fan.modernformsfan_fan",
ATTR_PERCENTAGE: 100,
},
blocking=True,
)
await hass.async_block_till_done()
fan_mock.assert_called_once_with(on=True, speed=6)
async def test_sleep_timer_services(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog
) -> None:
"""Test the change of state of the Modern Forms segments."""
await init_integration(hass, aioclient_mock)
with patch("aiomodernforms.ModernFormsDevice.fan") as fan_mock:
await hass.services.async_call(
DOMAIN,
SERVICE_SET_FAN_SLEEP_TIMER,
{ATTR_ENTITY_ID: "fan.modernformsfan_fan", ATTR_SLEEP_TIME: 1},
blocking=True,
)
await hass.async_block_till_done()
fan_mock.assert_called_once_with(sleep=60)
with patch("aiomodernforms.ModernFormsDevice.fan") as fan_mock:
await hass.services.async_call(
DOMAIN,
SERVICE_CLEAR_FAN_SLEEP_TIMER,
{ATTR_ENTITY_ID: "fan.modernformsfan_fan"},
blocking=True,
)
await hass.async_block_till_done()
fan_mock.assert_called_once_with(sleep=0)
async def test_change_direction(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog
) -> None:
"""Test the change of state of the Modern Forms segments."""
await init_integration(hass, aioclient_mock)
with patch("aiomodernforms.ModernFormsDevice.fan") as fan_mock:
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_SET_DIRECTION,
{
ATTR_ENTITY_ID: "fan.modernformsfan_fan",
ATTR_DIRECTION: DIRECTION_REVERSE,
},
blocking=True,
)
await hass.async_block_till_done()
fan_mock.assert_called_once_with(
direction=DIRECTION_REVERSE,
)
async def test_set_percentage(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog
) -> None:
"""Test the change of percentage for the Modern Forms fan."""
await init_integration(hass, aioclient_mock)
with patch("aiomodernforms.ModernFormsDevice.fan") as fan_mock:
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_SET_PERCENTAGE,
{
ATTR_ENTITY_ID: "fan.modernformsfan_fan",
ATTR_PERCENTAGE: 100,
},
blocking=True,
)
await hass.async_block_till_done()
fan_mock.assert_called_once_with(
on=True,
speed=6,
)
await init_integration(hass, aioclient_mock)
with patch("aiomodernforms.ModernFormsDevice.fan") as fan_mock:
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_SET_PERCENTAGE,
{
ATTR_ENTITY_ID: "fan.modernformsfan_fan",
ATTR_PERCENTAGE: 0,
},
blocking=True,
)
await hass.async_block_till_done()
fan_mock.assert_called_once_with(on=False)
async def test_fan_error(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog
) -> None:
"""Test error handling of the Modern Forms fans."""
await init_integration(hass, aioclient_mock)
aioclient_mock.clear_requests()
aioclient_mock.post("http://192.168.1.123:80/mf", text="", status=400)
with patch("homeassistant.components.modern_forms.ModernFormsDevice.update"):
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "fan.modernformsfan_fan"},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get("fan.modernformsfan_fan")
assert state.state == STATE_ON
assert "Invalid response from API" in caplog.text
async def test_fan_connection_error(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test error handling of the Moder Forms fans."""
await init_integration(hass, aioclient_mock)
with patch("homeassistant.components.modern_forms.ModernFormsDevice.update"), patch(
"homeassistant.components.modern_forms.ModernFormsDevice.fan",
side_effect=ModernFormsConnectionError,
):
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "fan.modernformsfan_fan"},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get("fan.modernformsfan_fan")
assert state.state == STATE_UNAVAILABLE

View File

@ -0,0 +1,60 @@
"""Tests for the Modern Forms integration."""
from unittest.mock import MagicMock, patch
from aiomodernforms import ModernFormsConnectionError
from homeassistant.components.modern_forms.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.components.modern_forms import (
init_integration,
modern_forms_no_light_call_mock,
)
from tests.test_util.aiohttp import AiohttpClientMocker
@patch(
"homeassistant.components.modern_forms.ModernFormsDevice.update",
side_effect=ModernFormsConnectionError,
)
async def test_config_entry_not_ready(
mock_update: MagicMock, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test the Modern Forms configuration entry not ready."""
entry = await init_integration(hass, aioclient_mock)
assert entry.state is ConfigEntryState.SETUP_RETRY
async def test_unload_config_entry(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test the Modern Forms configuration entry unloading."""
entry = await init_integration(hass, aioclient_mock)
assert hass.data[DOMAIN]
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert not hass.data.get(DOMAIN)
async def test_setting_unique_id(hass, aioclient_mock):
"""Test we set unique ID if not set yet."""
entry = await init_integration(hass, aioclient_mock)
assert hass.data[DOMAIN]
assert entry.unique_id == "AA:BB:CC:DD:EE:FF"
async def test_fan_only_device(hass, aioclient_mock):
"""Test we set unique ID if not set yet."""
await init_integration(
hass, aioclient_mock, mock_type=modern_forms_no_light_call_mock
)
entity_registry = er.async_get(hass)
fan_entry = entity_registry.async_get("fan.modernformsfan_fan")
assert fan_entry
light_entry = entity_registry.async_get("light.modernformsfan_light")
assert light_entry is None

View File

@ -0,0 +1,15 @@
{
"clientId": "MF_000000000000",
"mac": "AA:BB:CC:DD:EE:FF",
"lightType": "F6IN-120V-R1-30",
"fanType": "1818-56",
"fanMotorType": "DC125X25",
"productionLotNumber": "",
"productSku": "",
"owner": "someone@somewhere.com",
"federatedIdentity": "us-east-1:f3da237b-c19c-4f61-b387-0e6dde2e470b",
"deviceName": "ModernFormsFan",
"firmwareVersion": "01.03.0025",
"mainMcuFirmwareVersion": "01.03.3008",
"firmwareUrl": ""
}

View File

@ -0,0 +1,14 @@
{
"clientId": "MF_000000000000",
"mac": "AA:BB:CC:DD:EE:FF",
"fanType": "1818-56",
"fanMotorType": "DC125X25",
"productionLotNumber": "",
"productSku": "",
"owner": "someone@somewhere.com",
"federatedIdentity": "us-east-1:f3da237b-c19c-4f61-b387-0e6dde2e470b",
"deviceName": "ModernFormsFan",
"firmwareVersion": "01.03.0025",
"mainMcuFirmwareVersion": "01.03.3008",
"firmwareUrl": ""
}

View File

@ -0,0 +1,17 @@
{
"adaptiveLearning": false,
"awayModeEnabled": false,
"clientId": "MF_000000000000",
"decommission": false,
"factoryReset": false,
"fanDirection": "forward",
"fanOn": true,
"fanSleepTimer": 0,
"fanSpeed": 3,
"lightBrightness": 50,
"lightOn": true,
"lightSleepTimer": 0,
"resetRfPairList": false,
"rfPairModeActive": false,
"schedule": ""
}

View File

@ -0,0 +1,14 @@
{
"adaptiveLearning": false,
"awayModeEnabled": false,
"clientId": "MF_000000000000",
"decommission": false,
"factoryReset": false,
"fanDirection": "forward",
"fanOn": true,
"fanSleepTimer": 0,
"fanSpeed": 3,
"resetRfPairList": false,
"rfPairModeActive": false,
"schedule": ""
}