diff --git a/CODEOWNERS b/CODEOWNERS index 405c624c22d..6471d547be3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py new file mode 100644 index 00000000000..7530f7658c7 --- /dev/null +++ b/homeassistant/components/modern_forms/__init__.py @@ -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}", + } diff --git a/homeassistant/components/modern_forms/config_flow.py b/homeassistant/components/modern_forms/config_flow.py new file mode 100644 index 00000000000..67eb9cef0e4 --- /dev/null +++ b/homeassistant/components/modern_forms/config_flow.py @@ -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 {}, + ) diff --git a/homeassistant/components/modern_forms/const.py b/homeassistant/components/modern_forms/const.py new file mode 100644 index 00000000000..60791d97e64 --- /dev/null +++ b/homeassistant/components/modern_forms/const.py @@ -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 diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py new file mode 100644 index 00000000000..86c68df6eee --- /dev/null +++ b/homeassistant/components/modern_forms/fan.py @@ -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]]) diff --git a/homeassistant/components/modern_forms/manifest.json b/homeassistant/components/modern_forms/manifest.json new file mode 100644 index 00000000000..11d50e7353b --- /dev/null +++ b/homeassistant/components/modern_forms/manifest.json @@ -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" +} diff --git a/homeassistant/components/modern_forms/services.yaml b/homeassistant/components/modern_forms/services.yaml new file mode 100644 index 00000000000..6eeb423c36f --- /dev/null +++ b/homeassistant/components/modern_forms/services.yaml @@ -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 diff --git a/homeassistant/components/modern_forms/strings.json b/homeassistant/components/modern_forms/strings.json new file mode 100644 index 00000000000..097217692ae --- /dev/null +++ b/homeassistant/components/modern_forms/strings.json @@ -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%]" + } + } +} diff --git a/homeassistant/components/modern_forms/translations/en.json b/homeassistant/components/modern_forms/translations/en.json new file mode 100644 index 00000000000..f25f5124ab4 --- /dev/null +++ b/homeassistant/components/modern_forms/translations/en.json @@ -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" +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 79245491a7e..b887574c055 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -156,6 +156,7 @@ FLOWS = [ "mill", "minecraft_server", "mobile_app", + "modern_forms", "monoprice", "motion_blinds", "motioneye", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 125ff34206a..11fd47469f8 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -60,6 +60,12 @@ ZEROCONF = { "domain": "devolo_home_control" } ], + "_easylink._tcp.local.": [ + { + "domain": "modern_forms", + "name": "wac*" + } + ], "_elg._tcp.local.": [ { "domain": "elgato" diff --git a/requirements_all.txt b/requirements_all.txt index 14b38e89d83..15f6de35a05 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8404675bea3..df69e38a5a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/modern_forms/__init__.py b/tests/components/modern_forms/__init__.py new file mode 100644 index 00000000000..54fcf53ce89 --- /dev/null +++ b/tests/components/modern_forms/__init__.py @@ -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 diff --git a/tests/components/modern_forms/test_config_flow.py b/tests/components/modern_forms/test_config_flow.py new file mode 100644 index 00000000000..e7b01bf2fd4 --- /dev/null +++ b/tests/components/modern_forms/test_config_flow.py @@ -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" diff --git a/tests/components/modern_forms/test_fan.py b/tests/components/modern_forms/test_fan.py new file mode 100644 index 00000000000..bac9a5ed07c --- /dev/null +++ b/tests/components/modern_forms/test_fan.py @@ -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 diff --git a/tests/components/modern_forms/test_init.py b/tests/components/modern_forms/test_init.py new file mode 100644 index 00000000000..6ef7b563918 --- /dev/null +++ b/tests/components/modern_forms/test_init.py @@ -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 diff --git a/tests/fixtures/modern_forms/device_info.json b/tests/fixtures/modern_forms/device_info.json new file mode 100644 index 00000000000..e63f79fd468 --- /dev/null +++ b/tests/fixtures/modern_forms/device_info.json @@ -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": "" +} diff --git a/tests/fixtures/modern_forms/device_info_no_light.json b/tests/fixtures/modern_forms/device_info_no_light.json new file mode 100644 index 00000000000..5557af57531 --- /dev/null +++ b/tests/fixtures/modern_forms/device_info_no_light.json @@ -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": "" +} diff --git a/tests/fixtures/modern_forms/device_status.json b/tests/fixtures/modern_forms/device_status.json new file mode 100644 index 00000000000..c982f884375 --- /dev/null +++ b/tests/fixtures/modern_forms/device_status.json @@ -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": "" +} diff --git a/tests/fixtures/modern_forms/device_status_no_light.json b/tests/fixtures/modern_forms/device_status_no_light.json new file mode 100644 index 00000000000..ca499b271fb --- /dev/null +++ b/tests/fixtures/modern_forms/device_status_no_light.json @@ -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": "" +}