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:
tetienne 2019-06-11 16:45:34 +01:00 committed by Paulus Schoutsen
parent 046a4fc401
commit 0a7919a279
16 changed files with 565 additions and 0 deletions

View file

@ -561,6 +561,7 @@ omit =
homeassistant/components/solaredge/sensor.py
homeassistant/components/solaredge_local/sensor.py
homeassistant/components/solax/sensor.py
homeassistant/components/somfy/*
homeassistant/components/somfy_mylink/*
homeassistant/components/sonarr/sensor.py
homeassistant/components/songpal/media_player.py

View file

@ -223,6 +223,7 @@ homeassistant/components/smarty/* @z0mbieprocess
homeassistant/components/smtp/* @fabaff
homeassistant/components/solaredge_local/* @drobtravels
homeassistant/components/solax/* @squishykid
homeassistant/components/somfy/* @tetienne
homeassistant/components/sonos/* @amelchio
homeassistant/components/spaceapi/* @fabaff
homeassistant/components/spider/* @peternijssen

View 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"
}
}

View 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"
}
}

View 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

View 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>"
)

View file

@ -0,0 +1,5 @@
"""Define constants for the Somfy component."""
DOMAIN = 'somfy'
CLIENT_ID = 'client_id'
CLIENT_SECRET = 'client_secret'

View 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()

View 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"
]
}

View 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"
}
}

View file

@ -43,6 +43,7 @@ FLOWS = [
"simplisafe",
"smartthings",
"smhi",
"somfy",
"sonos",
"tellduslive",
"toon",

View file

@ -1210,6 +1210,9 @@ pymailgunner==1.4
# homeassistant.components.mediaroom
pymediaroom==0.6.4
# homeassistant.components.somfy
pymfy==0.5.2
# homeassistant.components.xiaomi_tv
pymitv==1.4.3

View file

@ -250,6 +250,9 @@ pyiqvia==0.2.1
# homeassistant.components.litejet
pylitejet==0.1
# homeassistant.components.somfy
pymfy==0.5.2
# homeassistant.components.monoprice
pymonoprice==0.3

View file

@ -107,6 +107,7 @@ TEST_REQUIREMENTS = (
'pyhomematic',
'pyiqvia',
'pylitejet',
'pymfy',
'pymonoprice',
'pynx584',
'pyopenuv',

View file

@ -0,0 +1 @@
"""Tests for the Somfy component."""

View 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'