diff --git a/.coveragerc b/.coveragerc index 7186718fecd..e256be60466 100644 --- a/.coveragerc +++ b/.coveragerc @@ -542,6 +542,10 @@ omit = homeassistant/components/modbus/cover.py homeassistant/components/modbus/switch.py homeassistant/components/modem_callerid/sensor.py + homeassistant/components/motion_blinds/__init__.py + homeassistant/components/motion_blinds/const.py + homeassistant/components/motion_blinds/cover.py + homeassistant/components/motion_blinds/sensor.py homeassistant/components/mpchc/media_player.py homeassistant/components/mpd/media_player.py homeassistant/components/mqtt_room/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index a1de0a34518..1c63bd37c45 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -277,6 +277,7 @@ homeassistant/components/mobile_app/* @robbiet480 homeassistant/components/modbus/* @adamchengtkc @janiversen @vzahradnik homeassistant/components/monoprice/* @etsinko @OnFreund homeassistant/components/moon/* @fabaff +homeassistant/components/motion_blinds/* @starkillerOG homeassistant/components/mpd/* @fabaff homeassistant/components/mqtt/* @home-assistant/core @emontnemery homeassistant/components/msteams/* @peroyvind diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py new file mode 100644 index 00000000000..72929e1ecb7 --- /dev/null +++ b/homeassistant/components/motion_blinds/__init__.py @@ -0,0 +1,101 @@ +"""The motion_blinds component.""" +from asyncio import TimeoutError as AsyncioTimeoutError +from datetime import timedelta +import logging +from socket import timeout + +from homeassistant import config_entries, core +from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY, MANUFACTURER +from .gateway import ConnectMotionGateway + +_LOGGER = logging.getLogger(__name__) + +MOTION_PLATFORMS = ["cover", "sensor"] + + +async def async_setup(hass: core.HomeAssistant, config: dict): + """Set up the Motion Blinds component.""" + return True + + +async def async_setup_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +): + """Set up the motion_blinds components from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + host = entry.data[CONF_HOST] + key = entry.data[CONF_API_KEY] + + # Connect to motion gateway + connect_gateway_class = ConnectMotionGateway(hass) + if not await connect_gateway_class.async_connect_gateway(host, key): + raise ConfigEntryNotReady + motion_gateway = connect_gateway_class.gateway_device + + def update_gateway(): + """Call all updates using one async_add_executor_job.""" + motion_gateway.Update() + for blind in motion_gateway.device_list.values(): + blind.Update() + + async def async_update_data(): + """Fetch data from the gateway and blinds.""" + try: + await hass.async_add_executor_job(update_gateway) + except timeout as socket_timeout: + raise AsyncioTimeoutError from socket_timeout + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name=entry.title, + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=10), + ) + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + + hass.data[DOMAIN][entry.entry_id] = { + KEY_GATEWAY: motion_gateway, + KEY_COORDINATOR: coordinator, + } + + device_registry = await dr.async_get_registry(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, motion_gateway.mac)}, + identifiers={(DOMAIN, entry.unique_id)}, + manufacturer=MANUFACTURER, + name=entry.title, + model="Wi-Fi bridge", + sw_version=motion_gateway.protocol, + ) + + for component in MOTION_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry( + hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry +): + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_forward_entry_unload( + config_entry, "cover" + ) + + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py new file mode 100644 index 00000000000..fbee7d1b439 --- /dev/null +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -0,0 +1,64 @@ +"""Config flow to configure Motion Blinds using their WLAN API.""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_HOST + +# pylint: disable=unused-import +from .const import DOMAIN +from .gateway import ConnectMotionGateway + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_GATEWAY_NAME = "Motion Gateway" + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_API_KEY): vol.All(str, vol.Length(min=16, max=16)), + } +) + + +class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Motion Blinds config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize the Motion Blinds flow.""" + self.host = None + self.key = None + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + if user_input is not None: + self.host = user_input[CONF_HOST] + self.key = user_input[CONF_API_KEY] + return await self.async_step_connect() + + return self.async_show_form( + step_id="user", data_schema=CONFIG_SCHEMA, errors=errors + ) + + async def async_step_connect(self, user_input=None): + """Connect to the Motion Gateway.""" + + connect_gateway_class = ConnectMotionGateway(self.hass) + if not await connect_gateway_class.async_connect_gateway(self.host, self.key): + return self.async_abort(reason="connection_error") + motion_gateway = connect_gateway_class.gateway_device + + mac_address = motion_gateway.mac + + await self.async_set_unique_id(mac_address) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=DEFAULT_GATEWAY_NAME, + data={CONF_HOST: self.host, CONF_API_KEY: self.key}, + ) diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py new file mode 100644 index 00000000000..c80c8f881cd --- /dev/null +++ b/homeassistant/components/motion_blinds/const.py @@ -0,0 +1,6 @@ +"""Constants for the Motion Blinds component.""" +DOMAIN = "motion_blinds" +MANUFACTURER = "Motion, Coulisse B.V." + +KEY_GATEWAY = "gateway" +KEY_COORDINATOR = "coordinator" diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py new file mode 100644 index 00000000000..4273be3f435 --- /dev/null +++ b/homeassistant/components/motion_blinds/cover.py @@ -0,0 +1,256 @@ +"""Support for Motion Blinds using their WLAN API.""" + +import logging + +from motionblinds import BlindType + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + DEVICE_CLASS_AWNING, + DEVICE_CLASS_BLIND, + DEVICE_CLASS_CURTAIN, + DEVICE_CLASS_GATE, + DEVICE_CLASS_SHADE, + DEVICE_CLASS_SHUTTER, + CoverEntity, +) +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +POSITION_DEVICE_MAP = { + BlindType.RollerBlind: DEVICE_CLASS_SHADE, + BlindType.RomanBlind: DEVICE_CLASS_SHADE, + BlindType.HoneycombBlind: DEVICE_CLASS_SHADE, + BlindType.DimmingBlind: DEVICE_CLASS_SHADE, + BlindType.DayNightBlind: DEVICE_CLASS_SHADE, + BlindType.RollerShutter: DEVICE_CLASS_SHUTTER, + BlindType.Switch: DEVICE_CLASS_SHUTTER, + BlindType.RollerGate: DEVICE_CLASS_GATE, + BlindType.Awning: DEVICE_CLASS_AWNING, + BlindType.Curtain: DEVICE_CLASS_CURTAIN, + BlindType.CurtainLeft: DEVICE_CLASS_CURTAIN, + BlindType.CurtainRight: DEVICE_CLASS_CURTAIN, +} + +TILT_DEVICE_MAP = { + BlindType.VenetianBlind: DEVICE_CLASS_BLIND, + BlindType.ShangriLaBlind: DEVICE_CLASS_BLIND, + BlindType.DoubleRoller: DEVICE_CLASS_SHADE, +} + +TDBU_DEVICE_MAP = { + BlindType.TopDownBottomUp: DEVICE_CLASS_SHADE, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Motion Blind from a config entry.""" + entities = [] + motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY] + coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + + for blind in motion_gateway.device_list.values(): + if blind.type in POSITION_DEVICE_MAP: + entities.append( + MotionPositionDevice( + coordinator, blind, POSITION_DEVICE_MAP[blind.type], config_entry + ) + ) + + elif blind.type in TILT_DEVICE_MAP: + entities.append( + MotionTiltDevice( + coordinator, blind, TILT_DEVICE_MAP[blind.type], config_entry + ) + ) + + elif blind.type in TDBU_DEVICE_MAP: + entities.append( + MotionTDBUDevice( + coordinator, blind, TDBU_DEVICE_MAP[blind.type], config_entry, "Top" + ) + ) + entities.append( + MotionTDBUDevice( + coordinator, + blind, + TDBU_DEVICE_MAP[blind.type], + config_entry, + "Bottom", + ) + ) + + else: + _LOGGER.warning("Blind type '%s' not yet supported", blind.blind_type) + + async_add_entities(entities) + + +class MotionPositionDevice(CoordinatorEntity, CoverEntity): + """Representation of a Motion Blind Device.""" + + def __init__(self, coordinator, blind, device_class, config_entry): + """Initialize the blind.""" + super().__init__(coordinator) + + self._blind = blind + self._device_class = device_class + self._config_entry = config_entry + + @property + def unique_id(self): + """Return the unique id of the blind.""" + return self._blind.mac + + @property + def device_info(self): + """Return the device info of the blind.""" + device_info = { + "identifiers": {(DOMAIN, self._blind.mac)}, + "manufacturer": MANUFACTURER, + "name": f"{self._blind.blind_type}-{self._blind.mac[12:]}", + "model": self._blind.blind_type, + "via_device": (DOMAIN, self._config_entry.unique_id), + } + + return device_info + + @property + def name(self): + """Return the name of the blind.""" + return f"{self._blind.blind_type}-{self._blind.mac[12:]}" + + @property + def current_cover_position(self): + """ + Return current position of cover. + + None is unknown, 0 is open, 100 is closed. + """ + if self._blind.position is None: + return None + return 100 - self._blind.position + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + return self._blind.position == 100 + + def open_cover(self, **kwargs): + """Open the cover.""" + self._blind.Open() + + def close_cover(self, **kwargs): + """Close cover.""" + self._blind.Close() + + def set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + self._blind.Set_position(100 - position) + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self._blind.Stop() + + +class MotionTiltDevice(MotionPositionDevice): + """Representation of a Motion Blind Device.""" + + @property + def current_cover_tilt_position(self): + """ + Return current angle of cover. + + None is unknown, 0 is closed/minimum tilt, 100 is fully open/maximum tilt. + """ + if self._blind.angle is None: + return None + return self._blind.angle * 100 / 180 + + def open_cover_tilt(self, **kwargs): + """Open the cover tilt.""" + self._blind.Set_angle(180) + + def close_cover_tilt(self, **kwargs): + """Close the cover tilt.""" + self._blind.Set_angle(0) + + def set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + angle = kwargs[ATTR_TILT_POSITION] * 180 / 100 + self._blind.Set_angle(angle) + + def stop_cover_tilt(self, **kwargs): + """Stop the cover.""" + self._blind.Stop() + + +class MotionTDBUDevice(MotionPositionDevice): + """Representation of a Motion Top Down Bottom Up blind Device.""" + + def __init__(self, coordinator, blind, device_class, config_entry, motor): + """Initialize the blind.""" + super().__init__(coordinator, blind, device_class, config_entry) + self._motor = motor + self._motor_key = motor[0] + + if self._motor not in ["Bottom", "Top"]: + _LOGGER.error("Unknown motor '%s'", self._motor) + + @property + def unique_id(self): + """Return the unique id of the blind.""" + return f"{self._blind.mac}-{self._motor}" + + @property + def name(self): + """Return the name of the blind.""" + return f"{self._blind.blind_type}-{self._motor}-{self._blind.mac[12:]}" + + @property + def current_cover_position(self): + """ + Return current position of cover. + + None is unknown, 0 is open, 100 is closed. + """ + if self._blind.position is None: + return None + + return 100 - self._blind.position[self._motor_key] + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + if self._blind.position is None: + return None + + return self._blind.position[self._motor_key] == 100 + + def open_cover(self, **kwargs): + """Open the cover.""" + self._blind.Open(motor=self._motor_key) + + def close_cover(self, **kwargs): + """Close cover.""" + self._blind.Close(motor=self._motor_key) + + def set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + position = kwargs[ATTR_POSITION] + self._blind.Set_position(100 - position, motor=self._motor_key) + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self._blind.Stop(motor=self._motor_key) diff --git a/homeassistant/components/motion_blinds/gateway.py b/homeassistant/components/motion_blinds/gateway.py new file mode 100644 index 00000000000..e7e665d65f9 --- /dev/null +++ b/homeassistant/components/motion_blinds/gateway.py @@ -0,0 +1,45 @@ +"""Code to handle a Motion Gateway.""" +import logging +import socket + +from motionblinds import MotionGateway + +_LOGGER = logging.getLogger(__name__) + + +class ConnectMotionGateway: + """Class to async connect to a Motion Gateway.""" + + def __init__(self, hass): + """Initialize the entity.""" + self._hass = hass + self._gateway_device = None + + @property + def gateway_device(self): + """Return the class containing all connections to the gateway.""" + return self._gateway_device + + def update_gateway(self): + """Update all information of the gateway.""" + self.gateway_device.GetDeviceList() + self.gateway_device.Update() + + async def async_connect_gateway(self, host, key): + """Connect to the Motion Gateway.""" + _LOGGER.debug("Initializing with host %s (key %s...)", host, key[:3]) + self._gateway_device = MotionGateway(ip=host, key=key) + try: + # update device info and get the connected sub devices + await self._hass.async_add_executor_job(self.update_gateway) + except socket.timeout: + _LOGGER.error( + "Timeout trying to connect to Motion Gateway with host %s", host + ) + return False + _LOGGER.debug( + "Motion gateway mac: %s, protocol: %s detected", + self.gateway_device.mac, + self.gateway_device.protocol, + ) + return True diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json new file mode 100644 index 00000000000..84cf711ac97 --- /dev/null +++ b/homeassistant/components/motion_blinds/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "motion_blinds", + "name": "Motion Blinds", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/motion_blinds", + "requirements": ["motionblinds==0.1.6"], + "codeowners": ["@starkillerOG"] +} \ No newline at end of file diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py new file mode 100644 index 00000000000..81d555806ed --- /dev/null +++ b/homeassistant/components/motion_blinds/sensor.py @@ -0,0 +1,181 @@ +"""Support for Motion Blinds sensors.""" +import logging + +from motionblinds import BlindType + +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_SIGNAL_STRENGTH, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY + +_LOGGER = logging.getLogger(__name__) + +ATTR_BATTERY_VOLTAGE = "battery_voltage" +TYPE_BLIND = "blind" +TYPE_GATEWAY = "gateway" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Perform the setup for Motion Blinds.""" + entities = [] + motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY] + coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + + for blind in motion_gateway.device_list.values(): + entities.append(MotionSignalStrengthSensor(coordinator, blind, TYPE_BLIND)) + if blind.type == BlindType.TopDownBottomUp: + entities.append(MotionTDBUBatterySensor(coordinator, blind, "Bottom")) + entities.append(MotionTDBUBatterySensor(coordinator, blind, "Top")) + elif blind.battery_voltage > 0: + # Only add battery powered blinds + entities.append(MotionBatterySensor(coordinator, blind)) + + entities.append( + MotionSignalStrengthSensor(coordinator, motion_gateway, TYPE_GATEWAY) + ) + + async_add_entities(entities) + + +class MotionBatterySensor(CoordinatorEntity, Entity): + """ + Representation of a Motion Battery Sensor. + + Updates are done by the cover platform. + """ + + def __init__(self, coordinator, blind): + """Initialize the Motion Battery Sensor.""" + super().__init__(coordinator) + + self._blind = blind + + @property + def unique_id(self): + """Return the unique id of the blind.""" + return f"{self._blind.mac}-battery" + + @property + def device_info(self): + """Return the device info of the blind.""" + return {"identifiers": {(DOMAIN, self._blind.mac)}} + + @property + def name(self): + """Return the name of the blind battery sensor.""" + return f"{self._blind.blind_type}-battery-{self._blind.mac[12:]}" + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return PERCENTAGE + + @property + def device_class(self): + """Return the device class of this entity.""" + return DEVICE_CLASS_BATTERY + + @property + def state(self): + """Return the state of the sensor.""" + return self._blind.battery_level + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + return {ATTR_BATTERY_VOLTAGE: self._blind.battery_voltage} + + +class MotionTDBUBatterySensor(MotionBatterySensor): + """ + Representation of a Motion Battery Sensor for a Top Down Bottom Up blind. + + Updates are done by the cover platform. + """ + + def __init__(self, coordinator, blind, motor): + """Initialize the Motion Battery Sensor.""" + super().__init__(coordinator, blind) + + self._motor = motor + + @property + def unique_id(self): + """Return the unique id of the blind.""" + return f"{self._blind.mac}-{self._motor}-battery" + + @property + def name(self): + """Return the name of the blind battery sensor.""" + return f"{self._blind.blind_type}-{self._motor}-battery-{self._blind.mac[12:]}" + + @property + def state(self): + """Return the state of the sensor.""" + if self._blind.battery_level is None: + return None + return self._blind.battery_level[self._motor[0]] + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + attributes = {} + if self._blind.battery_voltage is not None: + attributes[ATTR_BATTERY_VOLTAGE] = self._blind.battery_voltage[ + self._motor[0] + ] + return attributes + + +class MotionSignalStrengthSensor(CoordinatorEntity, Entity): + """Representation of a Motion Signal Strength Sensor.""" + + def __init__(self, coordinator, device, device_type): + """Initialize the Motion Signal Strength Sensor.""" + super().__init__(coordinator) + + self._device = device + self._device_type = device_type + + @property + def unique_id(self): + """Return the unique id of the blind.""" + return f"{self._device.mac}-RSSI" + + @property + def device_info(self): + """Return the device info of the blind.""" + return {"identifiers": {(DOMAIN, self._device.mac)}} + + @property + def name(self): + """Return the name of the blind signal strength sensor.""" + if self._device_type == TYPE_GATEWAY: + return "Motion gateway signal strength" + return f"{self._device.blind_type} signal strength - {self._device.mac[12:]}" + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return SIGNAL_STRENGTH_DECIBELS_MILLIWATT + + @property + def device_class(self): + """Return the device class of this entity.""" + return DEVICE_CLASS_SIGNAL_STRENGTH + + @property + def entity_registry_enabled_default(self): + """Return if the entity should be enabled when first added to the entity registry.""" + return False + + @property + def state(self): + """Return the state of the sensor.""" + return self._device.RSSI diff --git a/homeassistant/components/motion_blinds/strings.json b/homeassistant/components/motion_blinds/strings.json new file mode 100644 index 00000000000..d9c8a4099ac --- /dev/null +++ b/homeassistant/components/motion_blinds/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "flow_title": "Motion Blinds", + "step": { + "user": { + "title": "Motion Blinds", + "description": "You will need the 16 character API Key, see https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key for instructions", + "data": { + "host": "[%key:common::config_flow::data::ip%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "connection_error": "[%key:common::config_flow::error::cannot_connect%]" + } + } +} diff --git a/homeassistant/components/motion_blinds/translations/en.json b/homeassistant/components/motion_blinds/translations/en.json new file mode 100644 index 00000000000..2224ea38601 --- /dev/null +++ b/homeassistant/components/motion_blinds/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Config flow for this Motion gateway is already in progress", + "connection_error": "Failed to connect, please try again" + }, + "flow_title": "Motion Blinds", + "step": { + "user": { + "data": { + "host": "IP address", + "api_key": "API key" + }, + "description": "You will need the 16 character API Key, see https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key for instructions", + "title": "Motion Blinds" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 531e575dc0a..02fdac9ed3e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -121,6 +121,7 @@ FLOWS = [ "minecraft_server", "mobile_app", "monoprice", + "motion_blinds", "mqtt", "myq", "neato", diff --git a/requirements_all.txt b/requirements_all.txt index 2990802b2f0..8fd652e7628 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -951,6 +951,9 @@ minio==4.0.9 # homeassistant.components.mitemp_bt mitemp_bt==0.0.3 +# homeassistant.components.motion_blinds +motionblinds==0.1.6 + # homeassistant.components.tts mutagen==1.45.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 006c2c78de6..bdc982b1b23 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -473,6 +473,9 @@ millheater==0.4.0 # homeassistant.components.minio minio==4.0.9 +# homeassistant.components.motion_blinds +motionblinds==0.1.6 + # homeassistant.components.tts mutagen==1.45.1 diff --git a/tests/components/motion_blinds/__init__.py b/tests/components/motion_blinds/__init__.py new file mode 100644 index 00000000000..1c77ce16922 --- /dev/null +++ b/tests/components/motion_blinds/__init__.py @@ -0,0 +1 @@ +"""Tests for the Motion Blinds integration.""" diff --git a/tests/components/motion_blinds/test_config_flow.py b/tests/components/motion_blinds/test_config_flow.py new file mode 100644 index 00000000000..faa3e7115b8 --- /dev/null +++ b/tests/components/motion_blinds/test_config_flow.py @@ -0,0 +1,75 @@ +"""Test the Motion Blinds config flow.""" +import socket + +import pytest + +from homeassistant import config_entries +from homeassistant.components.motion_blinds.config_flow import DEFAULT_GATEWAY_NAME +from homeassistant.components.motion_blinds.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_HOST + +from tests.async_mock import patch + +TEST_HOST = "1.2.3.4" +TEST_API_KEY = "12ab345c-d67e-8f" + + +@pytest.fixture(name="motion_blinds_connect", autouse=True) +def motion_blinds_connect_fixture(): + """Mock motion blinds connection and entry setup.""" + with patch( + "homeassistant.components.motion_blinds.gateway.MotionGateway.GetDeviceList", + return_value=True, + ), patch( + "homeassistant.components.motion_blinds.gateway.MotionGateway.Update", + return_value=True, + ), patch( + "homeassistant.components.motion_blinds.async_setup_entry", return_value=True + ): + yield + + +async def test_config_flow_manual_host_success(hass): + """Successful flow manually initialized by the user.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: TEST_HOST, CONF_API_KEY: TEST_API_KEY}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == DEFAULT_GATEWAY_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_API_KEY: TEST_API_KEY, + } + + +async def test_config_flow_connection_error(hass): + """Failed flow manually initialized by the user with connection timeout.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.motion_blinds.gateway.MotionGateway.GetDeviceList", + side_effect=socket.timeout, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: TEST_HOST, CONF_API_KEY: TEST_API_KEY}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "connection_error"