Somfy open api (#19548)
* CREATE Somfy component * CREATE cover Somfy platform * USE somfy id as unique id * UPDATE all the devices in one call to limit the number of call * FIX Don't load devices if not yet configured * IMP Replace configurator by a simple notification * ADD log in case state does not match * IMP wording * REMOVE debug stuf * ADD support for tilt position * UPDATE requirements * FIX Use code instead of authorization response - Will allow to setup Somfy without https * HANDLE stateless devices (Somfy RTS) * FIX import locally 3rd party library * UPDATE pymfy to 0.4.3 * ADD missing docstring * FIX For Somfy 100 means closed and 0 opened * FIX position can be None * ENHANCE error management when error 500 occurs at setup * FIX indent * ROLLBACK tilt modification - See https://community.home-assistant.io/t/somfy-tahoma-official-api/61448/90?u=tetienne * FIX Look for capability instead of state * DON'T use exception to test if a feature is available * UPDATE dependency * ADD device_info property * AVOID object creation in each method * REMOVE unused constants * ADD missing doc * IMP Only make one call to add_entities * USE dict[key] instead of get method * IMP Don't pass hass object to the entities * FIX Don't end logging messages with period * USE config entries instead of a cache file * IMPLEMENT async_unload_entry * CONSOLIDATE package - see home-assistant/architecture#124 * UPDATE to pymfy 0.5.1 * SIMPLIFY config flow * ADD French translation * FIX 80 vs 79 max length * ABORT flow asap * FIX A tupple was returned * MIGRATE to manifest.json * ADD a placeholder async_setup_platform coroutine - It's currently required and expected by the platform helper. * FIX codeowner * ADD missing translations file * USE new external step * UPGRADE pymfy version * Close Somfy tab automatically * ADD manufacturer - Somfy only for the moment. * HANDLE missing code or state in Somfy request * REMOVE unused strings * DECLARE somfy component to use config_flow * APPLY static check remarks * FIX async method cannot be called from sync context * FIX only unload what has been loaded during entry setup * DON't catch them all * DON'T log full stacktrace * ABORT conflig flow if configuration missing * OMIT Somfy files for coverage * ADD tests about Somfy config flow * ADD pymfy to the test dependencies
This commit is contained in:
parent
046a4fc401
commit
0a7919a279
16 changed files with 565 additions and 0 deletions
|
@ -561,6 +561,7 @@ omit =
|
||||||
homeassistant/components/solaredge/sensor.py
|
homeassistant/components/solaredge/sensor.py
|
||||||
homeassistant/components/solaredge_local/sensor.py
|
homeassistant/components/solaredge_local/sensor.py
|
||||||
homeassistant/components/solax/sensor.py
|
homeassistant/components/solax/sensor.py
|
||||||
|
homeassistant/components/somfy/*
|
||||||
homeassistant/components/somfy_mylink/*
|
homeassistant/components/somfy_mylink/*
|
||||||
homeassistant/components/sonarr/sensor.py
|
homeassistant/components/sonarr/sensor.py
|
||||||
homeassistant/components/songpal/media_player.py
|
homeassistant/components/songpal/media_player.py
|
||||||
|
|
|
@ -223,6 +223,7 @@ homeassistant/components/smarty/* @z0mbieprocess
|
||||||
homeassistant/components/smtp/* @fabaff
|
homeassistant/components/smtp/* @fabaff
|
||||||
homeassistant/components/solaredge_local/* @drobtravels
|
homeassistant/components/solaredge_local/* @drobtravels
|
||||||
homeassistant/components/solax/* @squishykid
|
homeassistant/components/solax/* @squishykid
|
||||||
|
homeassistant/components/somfy/* @tetienne
|
||||||
homeassistant/components/sonos/* @amelchio
|
homeassistant/components/sonos/* @amelchio
|
||||||
homeassistant/components/spaceapi/* @fabaff
|
homeassistant/components/spaceapi/* @fabaff
|
||||||
homeassistant/components/spider/* @peternijssen
|
homeassistant/components/spider/* @peternijssen
|
||||||
|
|
13
homeassistant/components/somfy/.translations/en.json
Normal file
13
homeassistant/components/somfy/.translations/en.json
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_setup": "You can only configure one Somfy account.",
|
||||||
|
"authorize_url_timeout": "Timeout generating authorize url.",
|
||||||
|
"missing_configuration": "The Somfy component is not configured. Please follow the documentation."
|
||||||
|
},
|
||||||
|
"create_entry": {
|
||||||
|
"default": "Successfully authenticated with Somfy."
|
||||||
|
},
|
||||||
|
"title": "Somfy"
|
||||||
|
}
|
||||||
|
}
|
13
homeassistant/components/somfy/.translations/fr.json
Normal file
13
homeassistant/components/somfy/.translations/fr.json
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_setup": "Vous ne pouvez configurer qu'un seul compte Somfy.",
|
||||||
|
"authorize_url_timeout": "Durée expirée pour la génération de l'url d'autorisation.",
|
||||||
|
"missing_configuration": "Le composant Somfy n'est pas configuré. Merci de suivre la documentation."
|
||||||
|
},
|
||||||
|
"create_entry": {
|
||||||
|
"default": "Authentification réussie avec Somfy."
|
||||||
|
},
|
||||||
|
"title": "Somfy"
|
||||||
|
}
|
||||||
|
}
|
160
homeassistant/components/somfy/__init__.py
Normal file
160
homeassistant/components/somfy/__init__.py
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
"""
|
||||||
|
Support for Somfy hubs.
|
||||||
|
|
||||||
|
For more details about this component, please refer to the documentation at
|
||||||
|
https://home-assistant.io/components/somfy/
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components.somfy import config_flow
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_TOKEN
|
||||||
|
from homeassistant.helpers.entity import Entity
|
||||||
|
from homeassistant.helpers.typing import HomeAssistantType
|
||||||
|
from homeassistant.util import Throttle
|
||||||
|
|
||||||
|
API = 'api'
|
||||||
|
|
||||||
|
DEVICES = 'devices'
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10)
|
||||||
|
|
||||||
|
DOMAIN = 'somfy'
|
||||||
|
|
||||||
|
CONF_CLIENT_ID = 'client_id'
|
||||||
|
CONF_CLIENT_SECRET = 'client_secret'
|
||||||
|
|
||||||
|
SOMFY_AUTH_CALLBACK_PATH = '/auth/somfy/callback'
|
||||||
|
SOMFY_AUTH_START = '/auth/somfy'
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = vol.Schema({
|
||||||
|
DOMAIN: vol.Schema({
|
||||||
|
vol.Required(CONF_CLIENT_ID): cv.string,
|
||||||
|
vol.Required(CONF_CLIENT_SECRET): cv.string
|
||||||
|
})
|
||||||
|
}, extra=vol.ALLOW_EXTRA)
|
||||||
|
|
||||||
|
SOMFY_COMPONENTS = ['cover']
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass, config):
|
||||||
|
"""Set up the Somfy component."""
|
||||||
|
if DOMAIN not in config:
|
||||||
|
return True
|
||||||
|
|
||||||
|
hass.data[DOMAIN] = {}
|
||||||
|
|
||||||
|
config_flow.register_flow_implementation(
|
||||||
|
hass, config[DOMAIN][CONF_CLIENT_ID],
|
||||||
|
config[DOMAIN][CONF_CLIENT_SECRET])
|
||||||
|
|
||||||
|
hass.async_create_task(
|
||||||
|
hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={'source': config_entries.SOURCE_IMPORT},
|
||||||
|
))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
|
||||||
|
"""Set up Somfy from a config entry."""
|
||||||
|
def token_saver(token):
|
||||||
|
_LOGGER.debug('Saving updated token')
|
||||||
|
entry.data[CONF_TOKEN] = token
|
||||||
|
update_entry = partial(
|
||||||
|
hass.config_entries.async_update_entry,
|
||||||
|
data={**entry.data}
|
||||||
|
)
|
||||||
|
hass.add_job(update_entry, entry)
|
||||||
|
|
||||||
|
# Force token update.
|
||||||
|
from pymfy.api.somfy_api import SomfyApi
|
||||||
|
hass.data[DOMAIN][API] = SomfyApi(
|
||||||
|
entry.data['refresh_args']['client_id'],
|
||||||
|
entry.data['refresh_args']['client_secret'],
|
||||||
|
token=entry.data[CONF_TOKEN],
|
||||||
|
token_updater=token_saver
|
||||||
|
)
|
||||||
|
|
||||||
|
await update_all_devices(hass)
|
||||||
|
|
||||||
|
for component in SOMFY_COMPONENTS:
|
||||||
|
hass.async_create_task(
|
||||||
|
hass.config_entries.async_forward_entry_setup(entry, component))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry):
|
||||||
|
"""Unload a config entry."""
|
||||||
|
hass.data[DOMAIN].pop(API, None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class SomfyEntity(Entity):
|
||||||
|
"""Representation of a generic Somfy device."""
|
||||||
|
|
||||||
|
def __init__(self, device, api):
|
||||||
|
"""Initialize the Somfy device."""
|
||||||
|
self.device = device
|
||||||
|
self.api = api
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_id(self):
|
||||||
|
"""Return the unique id base on the id returned by Somfy."""
|
||||||
|
return self.device.id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
"""Return the name of the device."""
|
||||||
|
return self.device.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self):
|
||||||
|
"""Return device specific attributes.
|
||||||
|
|
||||||
|
Implemented by platform classes.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'identifiers': {(DOMAIN, self.unique_id)},
|
||||||
|
'name': self.name,
|
||||||
|
'model': self.device.type,
|
||||||
|
'via_hub': (DOMAIN, self.device.site_id),
|
||||||
|
# For the moment, Somfy only returns their own device.
|
||||||
|
'manufacturer': 'Somfy'
|
||||||
|
}
|
||||||
|
|
||||||
|
async def async_update(self):
|
||||||
|
"""Update the device with the latest data."""
|
||||||
|
await update_all_devices(self.hass)
|
||||||
|
devices = self.hass.data[DOMAIN][DEVICES]
|
||||||
|
self.device = next((d for d in devices if d.id == self.device.id),
|
||||||
|
self.device)
|
||||||
|
|
||||||
|
def has_capability(self, capability):
|
||||||
|
"""Test if device has a capability."""
|
||||||
|
capabilities = self.device.capabilities
|
||||||
|
return bool([c for c in capabilities if c.name == capability])
|
||||||
|
|
||||||
|
|
||||||
|
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||||
|
async def update_all_devices(hass):
|
||||||
|
"""Update all the devices."""
|
||||||
|
from requests import HTTPError
|
||||||
|
try:
|
||||||
|
data = hass.data[DOMAIN]
|
||||||
|
data[DEVICES] = await hass.async_add_executor_job(
|
||||||
|
data[API].get_devices)
|
||||||
|
except HTTPError:
|
||||||
|
_LOGGER.warning("Cannot update devices")
|
||||||
|
return False
|
||||||
|
return True
|
146
homeassistant/components/somfy/config_flow.py
Normal file
146
homeassistant/components/somfy/config_flow.py
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
"""Config flow for Somfy."""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import async_timeout
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components.http import HomeAssistantView
|
||||||
|
from homeassistant.core import callback
|
||||||
|
from .const import CLIENT_ID, CLIENT_SECRET, DOMAIN
|
||||||
|
|
||||||
|
AUTH_CALLBACK_PATH = '/auth/somfy/callback'
|
||||||
|
AUTH_CALLBACK_NAME = 'auth:somfy:callback'
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def register_flow_implementation(hass, client_id, client_secret):
|
||||||
|
"""Register a flow implementation.
|
||||||
|
|
||||||
|
client_id: Client id.
|
||||||
|
client_secret: Client secret.
|
||||||
|
"""
|
||||||
|
hass.data[DOMAIN][CLIENT_ID] = client_id
|
||||||
|
hass.data[DOMAIN][CLIENT_SECRET] = client_secret
|
||||||
|
|
||||||
|
|
||||||
|
@config_entries.HANDLERS.register('somfy')
|
||||||
|
class SomfyFlowHandler(config_entries.ConfigFlow):
|
||||||
|
"""Handle a config flow."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Instantiate config flow."""
|
||||||
|
self.code = None
|
||||||
|
|
||||||
|
async def async_step_import(self, user_input=None):
|
||||||
|
"""Handle external yaml configuration."""
|
||||||
|
if self.hass.config_entries.async_entries(DOMAIN):
|
||||||
|
return self.async_abort(reason='already_setup')
|
||||||
|
return await self.async_step_auth()
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input=None):
|
||||||
|
"""Handle a flow start."""
|
||||||
|
if self.hass.config_entries.async_entries(DOMAIN):
|
||||||
|
return self.async_abort(reason='already_setup')
|
||||||
|
|
||||||
|
if DOMAIN not in self.hass.data:
|
||||||
|
return self.async_abort(reason='missing_configuration')
|
||||||
|
|
||||||
|
return await self.async_step_auth()
|
||||||
|
|
||||||
|
async def async_step_auth(self, user_input=None):
|
||||||
|
"""Create an entry for auth."""
|
||||||
|
# Flow has been triggered from Somfy website
|
||||||
|
if user_input:
|
||||||
|
return await self.async_step_code(user_input)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with async_timeout.timeout(10):
|
||||||
|
url, _ = await self._get_authorization_url()
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return self.async_abort(reason='authorize_url_timeout')
|
||||||
|
|
||||||
|
return self.async_external_step(
|
||||||
|
step_id='auth',
|
||||||
|
url=url
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_authorization_url(self):
|
||||||
|
"""Get Somfy authorization url."""
|
||||||
|
from pymfy.api.somfy_api import SomfyApi
|
||||||
|
client_id = self.hass.data[DOMAIN][CLIENT_ID]
|
||||||
|
client_secret = self.hass.data[DOMAIN][CLIENT_SECRET]
|
||||||
|
redirect_uri = '{}{}'.format(
|
||||||
|
self.hass.config.api.base_url, AUTH_CALLBACK_PATH)
|
||||||
|
api = SomfyApi(client_id, client_secret, redirect_uri)
|
||||||
|
|
||||||
|
self.hass.http.register_view(SomfyAuthCallbackView())
|
||||||
|
# Thanks to the state, we can forward the flow id to Somfy that will
|
||||||
|
# add it in the callback.
|
||||||
|
return await self.hass.async_add_executor_job(
|
||||||
|
api.get_authorization_url, self.flow_id)
|
||||||
|
|
||||||
|
async def async_step_code(self, code):
|
||||||
|
"""Received code for authentication."""
|
||||||
|
self.code = code
|
||||||
|
return self.async_external_step_done(next_step_id="creation")
|
||||||
|
|
||||||
|
async def async_step_creation(self, user_input=None):
|
||||||
|
"""Create Somfy api and entries."""
|
||||||
|
client_id = self.hass.data[DOMAIN][CLIENT_ID]
|
||||||
|
client_secret = self.hass.data[DOMAIN][CLIENT_SECRET]
|
||||||
|
code = self.code
|
||||||
|
from pymfy.api.somfy_api import SomfyApi
|
||||||
|
redirect_uri = '{}{}'.format(
|
||||||
|
self.hass.config.api.base_url, AUTH_CALLBACK_PATH)
|
||||||
|
api = SomfyApi(client_id, client_secret, redirect_uri)
|
||||||
|
token = await self.hass.async_add_executor_job(api.request_token, None,
|
||||||
|
code)
|
||||||
|
_LOGGER.info('Successfully authenticated Somfy')
|
||||||
|
return self.async_create_entry(
|
||||||
|
title='Somfy',
|
||||||
|
data={
|
||||||
|
'token': token,
|
||||||
|
'refresh_args': {
|
||||||
|
'client_id': client_id,
|
||||||
|
'client_secret': client_secret
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SomfyAuthCallbackView(HomeAssistantView):
|
||||||
|
"""Somfy Authorization Callback View."""
|
||||||
|
|
||||||
|
requires_auth = False
|
||||||
|
url = AUTH_CALLBACK_PATH
|
||||||
|
name = AUTH_CALLBACK_NAME
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get(request):
|
||||||
|
"""Receive authorization code."""
|
||||||
|
from aiohttp import web_response
|
||||||
|
|
||||||
|
if 'code' not in request.query or 'state' not in request.query:
|
||||||
|
return web_response.Response(
|
||||||
|
text="Missing code or state parameter in " + request.url
|
||||||
|
)
|
||||||
|
|
||||||
|
hass = request.app['hass']
|
||||||
|
hass.async_create_task(
|
||||||
|
hass.config_entries.flow.async_configure(
|
||||||
|
flow_id=request.query['state'],
|
||||||
|
user_input=request.query['code'],
|
||||||
|
))
|
||||||
|
|
||||||
|
return web_response.Response(
|
||||||
|
headers={
|
||||||
|
'content-type': 'text/html'
|
||||||
|
},
|
||||||
|
text="<script>window.close()</script>"
|
||||||
|
)
|
5
homeassistant/components/somfy/const.py
Normal file
5
homeassistant/components/somfy/const.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
"""Define constants for the Somfy component."""
|
||||||
|
|
||||||
|
DOMAIN = 'somfy'
|
||||||
|
CLIENT_ID = 'client_id'
|
||||||
|
CLIENT_SECRET = 'client_secret'
|
114
homeassistant/components/somfy/cover.py
Normal file
114
homeassistant/components/somfy/cover.py
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
"""
|
||||||
|
Support for Somfy Covers.
|
||||||
|
|
||||||
|
For more details about this platform, please refer to the documentation at
|
||||||
|
https://home-assistant.io/components/cover.somfy/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from homeassistant.components.cover import CoverDevice, ATTR_POSITION, \
|
||||||
|
ATTR_TILT_POSITION
|
||||||
|
from homeassistant.components.somfy import DOMAIN, SomfyEntity, DEVICES, API
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||||
|
"""Set up the Somfy cover platform."""
|
||||||
|
def get_covers():
|
||||||
|
"""Retrieve covers."""
|
||||||
|
from pymfy.api.devices.category import Category
|
||||||
|
|
||||||
|
categories = {Category.ROLLER_SHUTTER.value,
|
||||||
|
Category.INTERIOR_BLIND.value,
|
||||||
|
Category.EXTERIOR_BLIND.value}
|
||||||
|
|
||||||
|
devices = hass.data[DOMAIN][DEVICES]
|
||||||
|
|
||||||
|
return [SomfyCover(cover, hass.data[DOMAIN][API]) for cover in
|
||||||
|
devices if
|
||||||
|
categories & set(cover.categories)]
|
||||||
|
|
||||||
|
async_add_entities(await hass.async_add_executor_job(get_covers), True)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_platform(hass, config, async_add_entities,
|
||||||
|
discovery_info=None):
|
||||||
|
"""Old way of setting up platform.
|
||||||
|
|
||||||
|
Can only be called when a user accidentally mentions the platform in their
|
||||||
|
config. But even in that case it would have been ignored.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SomfyCover(SomfyEntity, CoverDevice):
|
||||||
|
"""Representation of a Somfy cover device."""
|
||||||
|
|
||||||
|
def __init__(self, device, api):
|
||||||
|
"""Initialize the Somfy device."""
|
||||||
|
from pymfy.api.devices.blind import Blind
|
||||||
|
super().__init__(device, api)
|
||||||
|
self.cover = Blind(self.device, self.api)
|
||||||
|
|
||||||
|
async def async_update(self):
|
||||||
|
"""Update the device with the latest data."""
|
||||||
|
from pymfy.api.devices.blind import Blind
|
||||||
|
await super().async_update()
|
||||||
|
self.cover = Blind(self.device, self.api)
|
||||||
|
|
||||||
|
def close_cover(self, **kwargs):
|
||||||
|
"""Close the cover."""
|
||||||
|
self.cover.close()
|
||||||
|
|
||||||
|
def open_cover(self, **kwargs):
|
||||||
|
"""Open the cover."""
|
||||||
|
self.cover.open()
|
||||||
|
|
||||||
|
def stop_cover(self, **kwargs):
|
||||||
|
"""Stop the cover."""
|
||||||
|
self.cover.stop()
|
||||||
|
|
||||||
|
def set_cover_position(self, **kwargs):
|
||||||
|
"""Move the cover shutter to a specific position."""
|
||||||
|
self.cover.set_position(100 - kwargs[ATTR_POSITION])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_cover_position(self):
|
||||||
|
"""Return the current position of cover shutter."""
|
||||||
|
position = None
|
||||||
|
if self.has_capability('position'):
|
||||||
|
position = 100 - self.cover.get_position()
|
||||||
|
return position
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_closed(self):
|
||||||
|
"""Return if the cover is closed."""
|
||||||
|
is_closed = None
|
||||||
|
if self.has_capability('position'):
|
||||||
|
is_closed = self.cover.is_closed()
|
||||||
|
return is_closed
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_cover_tilt_position(self):
|
||||||
|
"""Return current position of cover tilt.
|
||||||
|
|
||||||
|
None is unknown, 0 is closed, 100 is fully open.
|
||||||
|
"""
|
||||||
|
orientation = None
|
||||||
|
if self.has_capability('rotation'):
|
||||||
|
orientation = 100 - self.cover.orientation
|
||||||
|
return orientation
|
||||||
|
|
||||||
|
def set_cover_tilt_position(self, **kwargs):
|
||||||
|
"""Move the cover tilt to a specific position."""
|
||||||
|
self.cover.orientation = kwargs[ATTR_TILT_POSITION]
|
||||||
|
|
||||||
|
def open_cover_tilt(self, **kwargs):
|
||||||
|
"""Open the cover tilt."""
|
||||||
|
self.cover.orientation = 100
|
||||||
|
|
||||||
|
def close_cover_tilt(self, **kwargs):
|
||||||
|
"""Close the cover tilt."""
|
||||||
|
self.cover.orientation = 0
|
||||||
|
|
||||||
|
def stop_cover_tilt(self, **kwargs):
|
||||||
|
"""Stop the cover."""
|
||||||
|
self.cover.stop()
|
13
homeassistant/components/somfy/manifest.json
Normal file
13
homeassistant/components/somfy/manifest.json
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"domain": "somfy",
|
||||||
|
"name": "Somfy Open API",
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "https://www.home-assistant.io/components/somfy",
|
||||||
|
"dependencies": [],
|
||||||
|
"codeowners": [
|
||||||
|
"@tetienne"
|
||||||
|
],
|
||||||
|
"requirements": [
|
||||||
|
"pymfy==0.5.2"
|
||||||
|
]
|
||||||
|
}
|
13
homeassistant/components/somfy/strings.json
Normal file
13
homeassistant/components/somfy/strings.json
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_setup": "You can only configure one Somfy account.",
|
||||||
|
"authorize_url_timeout": "Timeout generating authorize url.",
|
||||||
|
"missing_configuration": "The Somfy component is not configured. Please follow the documentation."
|
||||||
|
},
|
||||||
|
"create_entry": {
|
||||||
|
"default": "Successfully authenticated with Somfy."
|
||||||
|
},
|
||||||
|
"title": "Somfy"
|
||||||
|
}
|
||||||
|
}
|
|
@ -43,6 +43,7 @@ FLOWS = [
|
||||||
"simplisafe",
|
"simplisafe",
|
||||||
"smartthings",
|
"smartthings",
|
||||||
"smhi",
|
"smhi",
|
||||||
|
"somfy",
|
||||||
"sonos",
|
"sonos",
|
||||||
"tellduslive",
|
"tellduslive",
|
||||||
"toon",
|
"toon",
|
||||||
|
|
|
@ -1210,6 +1210,9 @@ pymailgunner==1.4
|
||||||
# homeassistant.components.mediaroom
|
# homeassistant.components.mediaroom
|
||||||
pymediaroom==0.6.4
|
pymediaroom==0.6.4
|
||||||
|
|
||||||
|
# homeassistant.components.somfy
|
||||||
|
pymfy==0.5.2
|
||||||
|
|
||||||
# homeassistant.components.xiaomi_tv
|
# homeassistant.components.xiaomi_tv
|
||||||
pymitv==1.4.3
|
pymitv==1.4.3
|
||||||
|
|
||||||
|
|
|
@ -250,6 +250,9 @@ pyiqvia==0.2.1
|
||||||
# homeassistant.components.litejet
|
# homeassistant.components.litejet
|
||||||
pylitejet==0.1
|
pylitejet==0.1
|
||||||
|
|
||||||
|
# homeassistant.components.somfy
|
||||||
|
pymfy==0.5.2
|
||||||
|
|
||||||
# homeassistant.components.monoprice
|
# homeassistant.components.monoprice
|
||||||
pymonoprice==0.3
|
pymonoprice==0.3
|
||||||
|
|
||||||
|
|
|
@ -107,6 +107,7 @@ TEST_REQUIREMENTS = (
|
||||||
'pyhomematic',
|
'pyhomematic',
|
||||||
'pyiqvia',
|
'pyiqvia',
|
||||||
'pylitejet',
|
'pylitejet',
|
||||||
|
'pymfy',
|
||||||
'pymonoprice',
|
'pymonoprice',
|
||||||
'pynx584',
|
'pynx584',
|
||||||
'pyopenuv',
|
'pyopenuv',
|
||||||
|
|
1
tests/components/somfy/__init__.py
Normal file
1
tests/components/somfy/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"""Tests for the Somfy component."""
|
77
tests/components/somfy/test_config_flow.py
Normal file
77
tests/components/somfy/test_config_flow.py
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
"""Tests for the Somfy config flow."""
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from pymfy.api.somfy_api import SomfyApi
|
||||||
|
|
||||||
|
from homeassistant import data_entry_flow
|
||||||
|
from homeassistant.components.somfy import config_flow, DOMAIN
|
||||||
|
from homeassistant.components.somfy.config_flow import \
|
||||||
|
register_flow_implementation
|
||||||
|
from tests.common import MockConfigEntry, mock_coro
|
||||||
|
|
||||||
|
CLIENT_SECRET_VALUE = "5678"
|
||||||
|
|
||||||
|
CLIENT_ID_VALUE = "1234"
|
||||||
|
|
||||||
|
AUTH_URL = 'http://somfy.com'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_abort_if_no_configuration(hass):
|
||||||
|
"""Check flow abort when no configuration."""
|
||||||
|
flow = config_flow.SomfyFlowHandler()
|
||||||
|
flow.hass = hass
|
||||||
|
result = await flow.async_step_user()
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
assert result['reason'] == 'missing_configuration'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_abort_if_existing_entry(hass):
|
||||||
|
"""Check flow abort when an entry already exist."""
|
||||||
|
flow = config_flow.SomfyFlowHandler()
|
||||||
|
flow.hass = hass
|
||||||
|
MockConfigEntry(domain=DOMAIN).add_to_hass(hass)
|
||||||
|
result = await flow.async_step_import()
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
assert result['reason'] == 'already_setup'
|
||||||
|
result = await flow.async_step_user()
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
assert result['reason'] == 'already_setup'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_flow(hass):
|
||||||
|
"""Check classic use case."""
|
||||||
|
hass.data[DOMAIN] = {}
|
||||||
|
register_flow_implementation(hass, CLIENT_ID_VALUE, CLIENT_SECRET_VALUE)
|
||||||
|
flow = config_flow.SomfyFlowHandler()
|
||||||
|
flow.hass = hass
|
||||||
|
hass.config.api = Mock(base_url='https://example.com')
|
||||||
|
flow._get_authorization_url = Mock(
|
||||||
|
return_value=mock_coro((AUTH_URL, 'state')))
|
||||||
|
result = await flow.async_step_import()
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP
|
||||||
|
assert result['url'] == AUTH_URL
|
||||||
|
result = await flow.async_step_auth("my_super_code")
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP_DONE
|
||||||
|
assert result['step_id'] == 'creation'
|
||||||
|
assert flow.code == 'my_super_code'
|
||||||
|
with patch.object(SomfyApi, 'request_token',
|
||||||
|
return_value={"access_token": "super_token"}):
|
||||||
|
result = await flow.async_step_creation()
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result['data']['refresh_args'] == {
|
||||||
|
'client_id': CLIENT_ID_VALUE,
|
||||||
|
'client_secret': CLIENT_SECRET_VALUE
|
||||||
|
}
|
||||||
|
assert result['title'] == 'Somfy'
|
||||||
|
assert result['data']['token'] == {"access_token": "super_token"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_abort_if_authorization_timeout(hass):
|
||||||
|
"""Check Somfy authorization timeout."""
|
||||||
|
flow = config_flow.SomfyFlowHandler()
|
||||||
|
flow.hass = hass
|
||||||
|
flow._get_authorization_url = Mock(side_effect=asyncio.TimeoutError)
|
||||||
|
result = await flow.async_step_auth()
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
assert result['reason'] == 'authorize_url_timeout'
|
Loading…
Add table
Add a link
Reference in a new issue