Add HomematicIP Cloud Config Flow and Entries loading (#14861)

* Add HomematicIP Cloud to config flow

* Inititial trial for config_flow

* Integrations text files

* Load and write config_flow and init homematicip_cloud

* Split into dedicated files

* Ceanup of text messages

* Working config_flow

* Move imports inside a function

* Enable laoding even no accesspoints are defined

* Revert unnecassary changes in CONFIG_SCHEMA

* Better error handling

* fix flask8

* Migration to async for token generation

* A few fixes

* Simplify config_flow

* Bump version to 9.6 with renamed package

* Requirements file

* First fixes after review

* Implement async_step_import

* Cleanup for Config Flow

* First tests for homematicip_cloud setup

* Remove config_flow tests

* Really remove all things

* Fix comment

* Update picture

* Add support for async_setup_entry to switch and climate platform

* Update path of the config_flow picture

* Refactoring for better tesability

* Further tests implemented

* Move 3th party lib inside function

* Fix lint

* Update requirments_test_all.txt file

* UPdate of requirments_test_all.txt did not work

* Furder cleanup in websocket connection

* Remove a test for the hap

* Revert "Remove a test for the hap"

This reverts commit 968d58cba1.

* First tests implemented for config_flow

* Fix lint

* Rework of client registration process

* Implemented tests for config_flow 100% coverage

* Cleanup

* Cleanup comments and code

* Try to fix import problem

* Add homematicip to the test env requirements
pull/15225/merge
Mattias Welponer 2018-07-06 23:05:34 +02:00 committed by Paulus Schoutsen
parent 0f1bcfd63b
commit 9970965718
23 changed files with 1020 additions and 293 deletions

View File

@ -9,8 +9,8 @@ import logging
from homeassistant.components.binary_sensor import BinarySensorDevice
from homeassistant.components.homematicip_cloud import (
HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN,
ATTR_HOME_ID)
HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN,
HMIPC_HAPID)
DEPENDENCIES = ['homematicip_cloud']
@ -26,12 +26,15 @@ HMIP_OPEN = 'open'
async def async_setup_platform(hass, config, async_add_devices,
discovery_info=None):
"""Set up the HomematicIP binary sensor devices."""
"""Set up the binary sensor devices."""
pass
async def async_setup_entry(hass, config_entry, async_add_devices):
"""Set up the HomematicIP binary sensor from a config entry."""
from homematicip.device import (ShutterContact, MotionDetectorIndoor)
if discovery_info is None:
return
home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]]
home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home
devices = []
for device in home.devices:
if isinstance(device, ShutterContact):

View File

@ -12,8 +12,8 @@ from homeassistant.components.climate import (
STATE_AUTO, STATE_MANUAL)
from homeassistant.const import TEMP_CELSIUS
from homeassistant.components.homematicip_cloud import (
HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN,
ATTR_HOME_ID)
HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN,
HMIPC_HAPID)
_LOGGER = logging.getLogger(__name__)
@ -30,12 +30,14 @@ HMIP_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_HMIP.items()}
async def async_setup_platform(hass, config, async_add_devices,
discovery_info=None):
"""Set up the HomematicIP climate devices."""
pass
async def async_setup_entry(hass, config_entry, async_add_devices):
"""Set up the HomematicIP climate from a config entry."""
from homematicip.group import HeatingGroup
if discovery_info is None:
return
home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]]
home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home
devices = []
for device in home.groups:
if isinstance(device, HeatingGroup):

View File

@ -1,262 +0,0 @@
"""
Support for HomematicIP components.
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/homematicip_cloud/
"""
import asyncio
import logging
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.entity import Entity
from homeassistant.core import callback
REQUIREMENTS = ['homematicip==0.9.4']
_LOGGER = logging.getLogger(__name__)
DOMAIN = 'homematicip_cloud'
COMPONENTS = [
'sensor',
'binary_sensor',
'switch',
'light',
'climate',
]
CONF_NAME = 'name'
CONF_ACCESSPOINT = 'accesspoint'
CONF_AUTHTOKEN = 'authtoken'
CONFIG_SCHEMA = vol.Schema({
vol.Optional(DOMAIN, default=[]): vol.All(cv.ensure_list, [vol.Schema({
vol.Optional(CONF_NAME): vol.Any(cv.string),
vol.Required(CONF_ACCESSPOINT): cv.string,
vol.Required(CONF_AUTHTOKEN): cv.string,
})]),
}, extra=vol.ALLOW_EXTRA)
HMIP_ACCESS_POINT = 'Access Point'
HMIP_HUB = 'HmIP-HUB'
ATTR_HOME_ID = 'home_id'
ATTR_HOME_NAME = 'home_name'
ATTR_DEVICE_ID = 'device_id'
ATTR_DEVICE_LABEL = 'device_label'
ATTR_STATUS_UPDATE = 'status_update'
ATTR_FIRMWARE_STATE = 'firmware_state'
ATTR_UNREACHABLE = 'unreachable'
ATTR_LOW_BATTERY = 'low_battery'
ATTR_MODEL_TYPE = 'model_type'
ATTR_GROUP_TYPE = 'group_type'
ATTR_DEVICE_RSSI = 'device_rssi'
ATTR_DUTY_CYCLE = 'duty_cycle'
ATTR_CONNECTED = 'connected'
ATTR_SABOTAGE = 'sabotage'
ATTR_OPERATION_LOCK = 'operation_lock'
async def async_setup(hass, config):
"""Set up the HomematicIP component."""
from homematicip.base.base_connection import HmipConnectionError
hass.data.setdefault(DOMAIN, {})
accesspoints = config.get(DOMAIN, [])
for conf in accesspoints:
_websession = async_get_clientsession(hass)
_hmip = HomematicipConnector(hass, conf, _websession)
try:
await _hmip.init()
except HmipConnectionError:
_LOGGER.error('Failed to connect to the HomematicIP server, %s.',
conf.get(CONF_ACCESSPOINT))
return False
home = _hmip.home
home.name = conf.get(CONF_NAME)
home.label = HMIP_ACCESS_POINT
home.modelType = HMIP_HUB
hass.data[DOMAIN][home.id] = home
_LOGGER.info('Connected to the HomematicIP server, %s.',
conf.get(CONF_ACCESSPOINT))
homeid = {ATTR_HOME_ID: home.id}
for component in COMPONENTS:
hass.async_add_job(async_load_platform(hass, component, DOMAIN,
homeid, config))
hass.loop.create_task(_hmip.connect())
return True
class HomematicipConnector:
"""Manages HomematicIP http and websocket connection."""
def __init__(self, hass, config, websession):
"""Initialize HomematicIP cloud connection."""
from homematicip.async.home import AsyncHome
self._hass = hass
self._ws_close_requested = False
self._retry_task = None
self._tries = 0
self._accesspoint = config.get(CONF_ACCESSPOINT)
_authtoken = config.get(CONF_AUTHTOKEN)
self.home = AsyncHome(hass.loop, websession)
self.home.set_auth_token(_authtoken)
self.home.on_update(self.async_update)
self._accesspoint_connected = True
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.close())
async def init(self):
"""Initialize connection."""
await self.home.init(self._accesspoint)
await self.home.get_current_state()
@callback
def async_update(self, *args, **kwargs):
"""Async update the home device.
Triggered when the hmip HOME_CHANGED event has fired.
There are several occasions for this event to happen.
We are only interested to check whether the access point
is still connected. If not, device state changes cannot
be forwarded to hass. So if access point is disconnected all devices
are set to unavailable.
"""
if not self.home.connected:
_LOGGER.error(
"HMIP access point has lost connection with the cloud")
self._accesspoint_connected = False
self.set_all_to_unavailable()
elif not self._accesspoint_connected:
# Explicitly getting an update as device states might have
# changed during access point disconnect."""
job = self._hass.async_add_job(self.get_state())
job.add_done_callback(self.get_state_finished)
async def get_state(self):
"""Update hmip state and tell hass."""
await self.home.get_current_state()
self.update_all()
def get_state_finished(self, future):
"""Execute when get_state coroutine has finished."""
from homematicip.base.base_connection import HmipConnectionError
try:
future.result()
except HmipConnectionError:
# Somehow connection could not recover. Will disconnect and
# so reconnect loop is taking over.
_LOGGER.error(
"updating state after himp access point reconnect failed.")
self._hass.async_add_job(self.home.disable_events())
def set_all_to_unavailable(self):
"""Set all devices to unavailable and tell Hass."""
for device in self.home.devices:
device.unreach = True
self.update_all()
def update_all(self):
"""Signal all devices to update their state."""
for device in self.home.devices:
device.fire_update_event()
async def _handle_connection(self):
"""Handle websocket connection."""
from homematicip.base.base_connection import HmipConnectionError
await self.home.get_current_state()
hmip_events = await self.home.enable_events()
try:
await hmip_events
except HmipConnectionError:
return
async def connect(self):
"""Start websocket connection."""
self._tries = 0
while True:
await self._handle_connection()
if self._ws_close_requested:
break
self._ws_close_requested = False
self._tries += 1
try:
self._retry_task = self._hass.async_add_job(asyncio.sleep(
2 ** min(9, self._tries), loop=self._hass.loop))
await self._retry_task
except asyncio.CancelledError:
break
_LOGGER.info('Reconnect (%s) to the HomematicIP cloud server.',
self._tries)
async def close(self):
"""Close the websocket connection."""
self._ws_close_requested = True
if self._retry_task is not None:
self._retry_task.cancel()
await self.home.disable_events()
_LOGGER.info("Closed connection to HomematicIP cloud server.")
class HomematicipGenericDevice(Entity):
"""Representation of an HomematicIP generic device."""
def __init__(self, home, device, post=None):
"""Initialize the generic device."""
self._home = home
self._device = device
self.post = post
_LOGGER.info('Setting up %s (%s)', self.name,
self._device.modelType)
async def async_added_to_hass(self):
"""Register callbacks."""
self._device.on_update(self._device_changed)
def _device_changed(self, json, **kwargs):
"""Handle device state changes."""
_LOGGER.debug('Event %s (%s)', self.name, self._device.modelType)
self.async_schedule_update_ha_state()
@property
def name(self):
"""Return the name of the generic device."""
name = self._device.label
if self._home.name is not None:
name = "{} {}".format(self._home.name, name)
if self.post is not None:
name = "{} {}".format(name, self.post)
return name
@property
def should_poll(self):
"""No polling needed."""
return False
@property
def available(self):
"""Device available."""
return not self._device.unreach
@property
def device_state_attributes(self):
"""Return the state attributes of the generic device."""
return {
ATTR_LOW_BATTERY: self._device.lowBat,
ATTR_MODEL_TYPE: self._device.modelType
}

View File

@ -0,0 +1,30 @@
{
"config": {
"title": "HomematicIP Cloud",
"step": {
"init": {
"title": "Pick HomematicIP Accesspoint",
"data": {
"hapid": "Accesspoint ID (SGTIN)",
"pin": "Pin Code (optional)",
"name": "Name (optional, used as name prefix for all devices)"
}
},
"link": {
"title": "Link Accesspoint",
"description": "Press the blue button on the accesspoint and the submit button to register HomematicIP with Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)"
}
},
"error": {
"register_failed": "Failed to register, please try again.",
"invalid_pin": "Invalid PIN, please try again.",
"press_the_button": "Please press the blue button.",
"timeout_button": "Blue button press timeout, please try again."
},
"abort": {
"unknown": "Unknown error occurred.",
"conection_aborted": "Could not connect to HMIP server",
"already_configured": "Accesspoint is already configured"
}
}
}

View File

@ -0,0 +1,65 @@
"""
Support for HomematicIP components.
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/homematicip_cloud/
"""
import logging
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from .const import (
DOMAIN, HMIPC_HAPID, HMIPC_AUTHTOKEN, HMIPC_NAME,
CONF_ACCESSPOINT, CONF_AUTHTOKEN, CONF_NAME)
# Loading the config flow file will register the flow
from .config_flow import configured_haps
from .hap import HomematicipHAP, HomematicipAuth # noqa: F401
from .device import HomematicipGenericDevice # noqa: F401
REQUIREMENTS = ['homematicip==0.9.6']
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema({
vol.Optional(DOMAIN, default=[]): vol.All(cv.ensure_list, [vol.Schema({
vol.Optional(CONF_NAME, default=''): vol.Any(cv.string),
vol.Required(CONF_ACCESSPOINT): cv.string,
vol.Required(CONF_AUTHTOKEN): cv.string,
})]),
}, extra=vol.ALLOW_EXTRA)
async def async_setup(hass, config):
"""Set up the HomematicIP component."""
hass.data[DOMAIN] = {}
accesspoints = config.get(DOMAIN, [])
for conf in accesspoints:
if conf[CONF_ACCESSPOINT] not in configured_haps(hass):
hass.async_add_job(hass.config_entries.flow.async_init(
DOMAIN, source='import', data={
HMIPC_HAPID: conf[CONF_ACCESSPOINT],
HMIPC_AUTHTOKEN: conf[CONF_AUTHTOKEN],
HMIPC_NAME: conf[CONF_NAME],
}
))
return True
async def async_setup_entry(hass, entry):
"""Set up an accsspoint from a config entry."""
hap = HomematicipHAP(hass, entry)
hapid = entry.data[HMIPC_HAPID].replace('-', '').upper()
hass.data[DOMAIN][hapid] = hap
return await hap.async_setup()
async def async_unload_entry(hass, entry):
"""Unload a config entry."""
hap = hass.data[DOMAIN].pop(entry.data[HMIPC_HAPID])
return await hap.async_reset()

View File

@ -0,0 +1,97 @@
"""Config flow to configure HomematicIP Cloud."""
import voluptuous as vol
from homeassistant import config_entries, data_entry_flow
from homeassistant.core import callback
from .const import (
DOMAIN as HMIPC_DOMAIN, _LOGGER,
HMIPC_HAPID, HMIPC_AUTHTOKEN, HMIPC_PIN, HMIPC_NAME)
from .hap import HomematicipAuth
@callback
def configured_haps(hass):
"""Return a set of the configured accesspoints."""
return set(entry.data[HMIPC_HAPID] for entry
in hass.config_entries.async_entries(HMIPC_DOMAIN))
@config_entries.HANDLERS.register(HMIPC_DOMAIN)
class HomematicipCloudFlowHandler(data_entry_flow.FlowHandler):
"""Config flow HomematicIP Cloud."""
VERSION = 1
def __init__(self):
"""Initialize HomematicIP Cloud config flow."""
self.auth = None
async def async_step_init(self, user_input=None):
"""Handle a flow start."""
errors = {}
if user_input is not None:
user_input[HMIPC_HAPID] = \
user_input[HMIPC_HAPID].replace('-', '').upper()
if user_input[HMIPC_HAPID] in configured_haps(self.hass):
return self.async_abort(reason='already_configured')
self.auth = HomematicipAuth(self.hass, user_input)
connected = await self.auth.async_setup()
if connected:
_LOGGER.info("Connection established")
return await self.async_step_link()
return self.async_show_form(
step_id='init',
data_schema=vol.Schema({
vol.Required(HMIPC_HAPID): str,
vol.Optional(HMIPC_PIN): str,
vol.Optional(HMIPC_NAME): str,
}),
errors=errors
)
async def async_step_link(self, user_input=None):
"""Attempt to link with the HomematicIP Cloud accesspoint."""
errors = {}
pressed = await self.auth.async_checkbutton()
if pressed:
authtoken = await self.auth.async_register()
if authtoken:
_LOGGER.info("Write config entry")
return self.async_create_entry(
title=self.auth.config.get(HMIPC_HAPID),
data={
HMIPC_HAPID: self.auth.config.get(HMIPC_HAPID),
HMIPC_AUTHTOKEN: authtoken,
HMIPC_NAME: self.auth.config.get(HMIPC_NAME)
})
return self.async_abort(reason='conection_aborted')
else:
errors['base'] = 'press_the_button'
return self.async_show_form(step_id='link', errors=errors)
async def async_step_import(self, import_info):
"""Import a new bridge as a config entry."""
hapid = import_info[HMIPC_HAPID]
authtoken = import_info[HMIPC_AUTHTOKEN]
name = import_info[HMIPC_NAME]
hapid = hapid.replace('-', '').upper()
if hapid in configured_haps(self.hass):
return self.async_abort(reason='already_configured')
_LOGGER.info('Imported authentication for %s', hapid)
return self.async_create_entry(
title=hapid,
data={
HMIPC_HAPID: hapid,
HMIPC_AUTHTOKEN: authtoken,
HMIPC_NAME: name
}
)

View File

@ -0,0 +1,23 @@
"""Constants for the HomematicIP Cloud component."""
import logging
_LOGGER = logging.getLogger('homeassistant.components.homematicip_cloud')
DOMAIN = 'homematicip_cloud'
COMPONENTS = [
'binary_sensor',
'climate',
'light',
'sensor',
'switch',
]
CONF_NAME = 'name'
CONF_ACCESSPOINT = 'accesspoint'
CONF_AUTHTOKEN = 'authtoken'
HMIPC_NAME = 'name'
HMIPC_HAPID = 'hapid'
HMIPC_AUTHTOKEN = 'authtoken'
HMIPC_PIN = 'pin'

View File

@ -0,0 +1,71 @@
"""GenericDevice for the HomematicIP Cloud component."""
import logging
from homeassistant.helpers.entity import Entity
_LOGGER = logging.getLogger(__name__)
ATTR_HOME_ID = 'home_id'
ATTR_HOME_NAME = 'home_name'
ATTR_DEVICE_ID = 'device_id'
ATTR_DEVICE_LABEL = 'device_label'
ATTR_STATUS_UPDATE = 'status_update'
ATTR_FIRMWARE_STATE = 'firmware_state'
ATTR_UNREACHABLE = 'unreachable'
ATTR_LOW_BATTERY = 'low_battery'
ATTR_MODEL_TYPE = 'model_type'
ATTR_GROUP_TYPE = 'group_type'
ATTR_DEVICE_RSSI = 'device_rssi'
ATTR_DUTY_CYCLE = 'duty_cycle'
ATTR_CONNECTED = 'connected'
ATTR_SABOTAGE = 'sabotage'
ATTR_OPERATION_LOCK = 'operation_lock'
class HomematicipGenericDevice(Entity):
"""Representation of an HomematicIP generic device."""
def __init__(self, home, device, post=None):
"""Initialize the generic device."""
self._home = home
self._device = device
self.post = post
_LOGGER.info('Setting up %s (%s)', self.name,
self._device.modelType)
async def async_added_to_hass(self):
"""Register callbacks."""
self._device.on_update(self._device_changed)
def _device_changed(self, json, **kwargs):
"""Handle device state changes."""
_LOGGER.debug('Event %s (%s)', self.name, self._device.modelType)
self.async_schedule_update_ha_state()
@property
def name(self):
"""Return the name of the generic device."""
name = self._device.label
if (self._home.name is not None and self._home.name != ''):
name = "{} {}".format(self._home.name, name)
if (self.post is not None and self.post != ''):
name = "{} {}".format(name, self.post)
return name
@property
def should_poll(self):
"""No polling needed."""
return False
@property
def available(self):
"""Device available."""
return not self._device.unreach
@property
def device_state_attributes(self):
"""Return the state attributes of the generic device."""
return {
ATTR_LOW_BATTERY: self._device.lowBat,
ATTR_MODEL_TYPE: self._device.modelType
}

View File

@ -0,0 +1,22 @@
"""Errors for the HomematicIP component."""
from homeassistant.exceptions import HomeAssistantError
class HmipcException(HomeAssistantError):
"""Base class for HomematicIP exceptions."""
class HmipcConnectionError(HmipcException):
"""Unable to connect to the HomematicIP cloud server."""
class HmipcConnectionWait(HmipcException):
"""Wait for registration to the HomematicIP cloud server."""
class HmipcRegistrationFailed(HmipcException):
"""Registration on HomematicIP cloud failed."""
class HmipcPressButton(HmipcException):
"""User needs to press the blue button."""

View File

@ -0,0 +1,256 @@
"""Accesspoint for the HomematicIP Cloud component."""
import asyncio
import logging
from homeassistant import config_entries
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.core import callback
from .const import (
HMIPC_HAPID, HMIPC_AUTHTOKEN, HMIPC_PIN, HMIPC_NAME,
COMPONENTS)
from .errors import HmipcConnectionError
_LOGGER = logging.getLogger(__name__)
class HomematicipAuth(object):
"""Manages HomematicIP client registration."""
def __init__(self, hass, config):
"""Initialize HomematicIP Cloud client registration."""
self.hass = hass
self.config = config
self.auth = None
async def async_setup(self):
"""Connect to HomematicIP for registration."""
try:
self.auth = await self.get_auth(
self.hass,
self.config.get(HMIPC_HAPID),
self.config.get(HMIPC_PIN)
)
return True
except HmipcConnectionError:
return False
async def async_checkbutton(self):
"""Check blue butten has been pressed."""
from homematicip.base.base_connection import HmipConnectionError
try:
await self.auth.isRequestAcknowledged()
return True
except HmipConnectionError:
return False
async def async_register(self):
"""Register client at HomematicIP."""
from homematicip.base.base_connection import HmipConnectionError
try:
authtoken = await self.auth.requestAuthToken()
await self.auth.confirmAuthToken(authtoken)
return authtoken
except HmipConnectionError:
return False
async def get_auth(self, hass, hapid, pin):
"""Create a HomematicIP access point object."""
from homematicip.aio.auth import AsyncAuth
from homematicip.base.base_connection import HmipConnectionError
auth = AsyncAuth(hass.loop, async_get_clientsession(hass))
print(auth)
try:
await auth.init(hapid)
if pin:
auth.pin = pin
await auth.connectionRequest('HomeAssistant')
except HmipConnectionError:
return False
return auth
class HomematicipHAP(object):
"""Manages HomematicIP http and websocket connection."""
def __init__(self, hass, config_entry):
"""Initialize HomematicIP cloud connection."""
self.hass = hass
self.config_entry = config_entry
self.home = None
self._ws_close_requested = False
self._retry_task = None
self._tries = 0
self._accesspoint_connected = True
self._retry_setup = None
async def async_setup(self, tries=0):
"""Initialize connection."""
try:
self.home = await self.get_hap(
self.hass,
self.config_entry.data.get(HMIPC_HAPID),
self.config_entry.data.get(HMIPC_AUTHTOKEN),
self.config_entry.data.get(HMIPC_NAME)
)
except HmipcConnectionError:
retry_delay = 2 ** min(tries + 1, 6)
_LOGGER.error("Error connecting to HomematicIP with HAP %s. "
"Retrying in %d seconds.",
self.config_entry.data.get(HMIPC_HAPID), retry_delay)
async def retry_setup(_now):
"""Retry setup."""
if await self.async_setup(tries + 1):
self.config_entry.state = config_entries.ENTRY_STATE_LOADED
self._retry_setup = self.hass.helpers.event.async_call_later(
retry_delay, retry_setup)
return False
_LOGGER.info('Connected to HomematicIP with HAP %s.',
self.config_entry.data.get(HMIPC_HAPID))
for component in COMPONENTS:
self.hass.async_add_job(
self.hass.config_entries.async_forward_entry_setup(
self.config_entry, component)
)
return True
@callback
def async_update(self, *args, **kwargs):
"""Async update the home device.
Triggered when the hmip HOME_CHANGED event has fired.
There are several occasions for this event to happen.
We are only interested to check whether the access point
is still connected. If not, device state changes cannot
be forwarded to hass. So if access point is disconnected all devices
are set to unavailable.
"""
if not self.home.connected:
_LOGGER.error(
"HMIP access point has lost connection with the cloud")
self._accesspoint_connected = False
self.set_all_to_unavailable()
elif not self._accesspoint_connected:
# Explicitly getting an update as device states might have
# changed during access point disconnect."""
job = self.hass.async_add_job(self.get_state())
job.add_done_callback(self.get_state_finished)
async def get_state(self):
"""Update hmip state and tell hass."""
await self.home.get_current_state()
self.update_all()
def get_state_finished(self, future):
"""Execute when get_state coroutine has finished."""
from homematicip.base.base_connection import HmipConnectionError
try:
future.result()
except HmipConnectionError:
# Somehow connection could not recover. Will disconnect and
# so reconnect loop is taking over.
_LOGGER.error(
"updating state after himp access point reconnect failed.")
self.hass.async_add_job(self.home.disable_events())
def set_all_to_unavailable(self):
"""Set all devices to unavailable and tell Hass."""
for device in self.home.devices:
device.unreach = True
self.update_all()
def update_all(self):
"""Signal all devices to update their state."""
for device in self.home.devices:
device.fire_update_event()
async def _handle_connection(self):
"""Handle websocket connection."""
from homematicip.base.base_connection import HmipConnectionError
try:
await self.home.get_current_state()
except HmipConnectionError:
return
hmip_events = await self.home.enable_events()
try:
await hmip_events
except HmipConnectionError:
return
async def async_connect(self):
"""Start websocket connection."""
from homematicip.base.base_connection import HmipConnectionError
tries = 0
while True:
try:
await self.home.get_current_state()
hmip_events = await self.home.enable_events()
tries = 0
await hmip_events
except HmipConnectionError:
pass
if self._ws_close_requested:
break
self._ws_close_requested = False
tries += 1
retry_delay = 2 ** min(tries + 1, 6)
_LOGGER.error("Error connecting to HomematicIP with HAP %s. "
"Retrying in %d seconds.",
self.config_entry.data.get(HMIPC_HAPID), retry_delay)
try:
self._retry_task = self.hass.async_add_job(asyncio.sleep(
retry_delay, loop=self.hass.loop))
await self._retry_task
except asyncio.CancelledError:
break
async def async_reset(self):
"""Close the websocket connection."""
self._ws_close_requested = True
if self._retry_setup is not None:
self._retry_setup.cancel()
if self._retry_task is not None:
self._retry_task.cancel()
self.home.disable_events()
_LOGGER.info("Closed connection to HomematicIP cloud server.")
for component in COMPONENTS:
await self.hass.config_entries.async_forward_entry_unload(
self.config_entry, component)
return True
async def get_hap(self, hass, hapid, authtoken, name):
"""Create a HomematicIP access point object."""
from homematicip.aio.home import AsyncHome
from homematicip.base.base_connection import HmipConnectionError
home = AsyncHome(hass.loop, async_get_clientsession(hass))
home.name = name
home.label = 'Access Point'
home.modelType = 'HmIP-HAP'
home.set_auth_token(authtoken)
try:
await home.init(hapid)
await home.get_current_state()
except HmipConnectionError:
raise HmipcConnectionError
home.on_update(self.async_update)
hass.loop.create_task(self.async_connect())
return home

View File

@ -0,0 +1,30 @@
{
"config": {
"title": "HomematicIP Cloud",
"step": {
"init": {
"title": "Pick HomematicIP Accesspoint",
"data": {
"hapid": "Accesspoint ID (SGTIN)",
"pin": "Pin Code (optional)",
"name": "Name (optional, used as name prefix for all devices)"
}
},
"link": {
"title": "Link Accesspoint",
"description": "Press the blue button on the accesspoint and the submit button to register HomematicIP with Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)"
}
},
"error": {
"register_failed": "Failed to register, please try again.",
"invalid_pin": "Invalid PIN, please try again.",
"press_the_button": "Please press the blue button.",
"timeout_button": "Blue button press timeout, please try again."
},
"abort": {
"unknown": "Unknown error occurred.",
"conection_aborted": "Could not connect to HMIP server",
"already_configured": "Accesspoint is already configured"
}
}
}

View File

@ -9,8 +9,8 @@ import logging
from homeassistant.components.light import Light
from homeassistant.components.homematicip_cloud import (
HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN,
ATTR_HOME_ID)
HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN,
HMIPC_HAPID)
DEPENDENCIES = ['homematicip_cloud']
@ -23,13 +23,16 @@ ATTR_PROFILE_MODE = 'profile_mode'
async def async_setup_platform(hass, config, async_add_devices,
discovery_info=None):
"""Set up the HomematicIP light devices."""
"""Old way of setting up HomematicIP lights."""
pass
async def async_setup_entry(hass, config_entry, async_add_devices):
"""Set up the HomematicIP lights from a config entry."""
from homematicip.device import (
BrandSwitchMeasuring)
if discovery_info is None:
return
home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]]
home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home
devices = []
for device in home.devices:
if isinstance(device, BrandSwitchMeasuring):

View File

@ -8,8 +8,8 @@ https://home-assistant.io/components/sensor.homematicip_cloud/
import logging
from homeassistant.components.homematicip_cloud import (
HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN,
ATTR_HOME_ID)
HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN,
HMIPC_HAPID)
from homeassistant.const import (
TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE)
@ -36,15 +36,17 @@ STATE_SABOTAGE = 'sabotage'
async def async_setup_platform(hass, config, async_add_devices,
discovery_info=None):
"""Set up the HomematicIP sensors devices."""
pass
async def async_setup_entry(hass, config_entry, async_add_devices):
"""Set up the HomematicIP sensors from a config entry."""
from homematicip.device import (
HeatingThermostat, TemperatureHumiditySensorWithoutDisplay,
TemperatureHumiditySensorDisplay, MotionDetectorIndoor)
if discovery_info is None:
return
home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]]
home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home
devices = [HomematicipAccesspointStatus(home)]
for device in home.devices:
if isinstance(device, HeatingThermostat):
devices.append(HomematicipHeatingThermostat(home, device))

View File

@ -95,7 +95,7 @@ def toggle(hass, entity_id=None):
async def async_setup(hass, config):
"""Track states and offer events for switches."""
component = EntityComponent(
component = hass.data[DOMAIN] = EntityComponent(
_LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_SWITCHES)
await component.async_setup(config)
@ -132,6 +132,16 @@ async def async_setup(hass, config):
return True
async def async_setup_entry(hass, entry):
"""Setup a config entry."""
return await hass.data[DOMAIN].async_setup_entry(entry)
async def async_unload_entry(hass, entry):
"""Unload a config entry."""
return await hass.data[DOMAIN].async_unload_entry(entry)
class SwitchDevice(ToggleEntity):
"""Representation of a switch."""

View File

@ -9,8 +9,8 @@ import logging
from homeassistant.components.switch import SwitchDevice
from homeassistant.components.homematicip_cloud import (
HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN,
ATTR_HOME_ID)
HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN,
HMIPC_HAPID)
DEPENDENCIES = ['homematicip_cloud']
@ -24,13 +24,16 @@ ATTR_PROFILE_MODE = 'profile_mode'
async def async_setup_platform(hass, config, async_add_devices,
discovery_info=None):
"""Set up the HomematicIP switch devices."""
pass
async def async_setup_entry(hass, config_entry, async_add_devices):
"""Set up the HomematicIP switch from a config entry."""
from homematicip.device import (
PlugableSwitch, PlugableSwitchMeasuring,
BrandSwitchMeasuring)
if discovery_info is None:
return
home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]]
home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home
devices = []
for device in home.devices:
if isinstance(device, BrandSwitchMeasuring):

View File

@ -127,6 +127,7 @@ HANDLERS = Registry()
FLOWS = [
'cast',
'deconz',
'homematicip_cloud',
'hue',
'nest',
'sonos',

View File

@ -421,7 +421,7 @@ home-assistant-frontend==20180704.0
# homekit==0.6
# homeassistant.components.homematicip_cloud
homematicip==0.9.4
homematicip==0.9.6
# homeassistant.components.remember_the_milk
httplib2==0.10.3

View File

@ -83,6 +83,9 @@ holidays==0.9.5
# homeassistant.components.frontend
home-assistant-frontend==20180704.0
# homeassistant.components.homematicip_cloud
homematicip==0.9.6
# homeassistant.components.influxdb
# homeassistant.components.sensor.influxdb
influxdb==5.0.0

View File

@ -56,6 +56,7 @@ TEST_REQUIREMENTS = (
'hbmqtt',
'holidays',
'home-assistant-frontend',
'homematicip',
'influxdb',
'libpurecoollink',
'libsoundtouch',

View File

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

View File

@ -0,0 +1,150 @@
"""Tests for HomematicIP Cloud config flow."""
from unittest.mock import patch
from homeassistant.components.homematicip_cloud import hap as hmipc
from homeassistant.components.homematicip_cloud import config_flow, const
from tests.common import MockConfigEntry, mock_coro
async def test_flow_works(hass):
"""Test config flow works."""
config = {
const.HMIPC_HAPID: 'ABC123',
const.HMIPC_PIN: '123',
const.HMIPC_NAME: 'hmip',
}
flow = config_flow.HomematicipCloudFlowHandler()
flow.hass = hass
hap = hmipc.HomematicipAuth(hass, config)
with patch.object(hap, 'get_auth', return_value=mock_coro()), \
patch.object(hmipc.HomematicipAuth, 'async_checkbutton',
return_value=mock_coro(True)), \
patch.object(hmipc.HomematicipAuth, 'async_register',
return_value=mock_coro(True)):
hap.authtoken = 'ABC'
result = await flow.async_step_init(user_input=config)
assert hap.authtoken == 'ABC'
assert result['type'] == 'create_entry'
async def test_flow_init_connection_error(hass):
"""Test config flow with accesspoint connection error."""
config = {
const.HMIPC_HAPID: 'ABC123',
const.HMIPC_PIN: '123',
const.HMIPC_NAME: 'hmip',
}
flow = config_flow.HomematicipCloudFlowHandler()
flow.hass = hass
with patch.object(hmipc.HomematicipAuth, 'async_setup',
return_value=mock_coro(False)):
result = await flow.async_step_init(user_input=config)
assert result['type'] == 'form'
async def test_flow_link_connection_error(hass):
"""Test config flow client registration connection error."""
config = {
const.HMIPC_HAPID: 'ABC123',
const.HMIPC_PIN: '123',
const.HMIPC_NAME: 'hmip',
}
flow = config_flow.HomematicipCloudFlowHandler()
flow.hass = hass
with patch.object(hmipc.HomematicipAuth, 'async_setup',
return_value=mock_coro(True)), \
patch.object(hmipc.HomematicipAuth, 'async_checkbutton',
return_value=mock_coro(True)), \
patch.object(hmipc.HomematicipAuth, 'async_register',
return_value=mock_coro(False)):
result = await flow.async_step_init(user_input=config)
assert result['type'] == 'abort'
async def test_flow_link_press_button(hass):
"""Test config flow ask for pressing the blue button."""
config = {
const.HMIPC_HAPID: 'ABC123',
const.HMIPC_PIN: '123',
const.HMIPC_NAME: 'hmip',
}
flow = config_flow.HomematicipCloudFlowHandler()
flow.hass = hass
with patch.object(hmipc.HomematicipAuth, 'async_setup',
return_value=mock_coro(True)), \
patch.object(hmipc.HomematicipAuth, 'async_checkbutton',
return_value=mock_coro(False)):
result = await flow.async_step_init(user_input=config)
assert result['type'] == 'form'
assert result['errors'] == {'base': 'press_the_button'}
async def test_init_flow_show_form(hass):
"""Test config flow shows up with a form."""
flow = config_flow.HomematicipCloudFlowHandler()
flow.hass = hass
result = await flow.async_step_init(user_input=None)
assert result['type'] == 'form'
async def test_init_already_configured(hass):
"""Test accesspoint is already configured."""
MockConfigEntry(domain=const.DOMAIN, data={
const.HMIPC_HAPID: 'ABC123',
}).add_to_hass(hass)
config = {
const.HMIPC_HAPID: 'ABC123',
const.HMIPC_PIN: '123',
const.HMIPC_NAME: 'hmip',
}
flow = config_flow.HomematicipCloudFlowHandler()
flow.hass = hass
result = await flow.async_step_init(user_input=config)
assert result['type'] == 'abort'
async def test_import_config(hass):
"""Test importing a host with an existing config file."""
flow = config_flow.HomematicipCloudFlowHandler()
flow.hass = hass
result = await flow.async_step_import({
hmipc.HMIPC_HAPID: 'ABC123',
hmipc.HMIPC_AUTHTOKEN: '123',
hmipc.HMIPC_NAME: 'hmip'
})
assert result['type'] == 'create_entry'
assert result['title'] == 'ABC123'
assert result['data'] == {
hmipc.HMIPC_HAPID: 'ABC123',
hmipc.HMIPC_AUTHTOKEN: '123',
hmipc.HMIPC_NAME: 'hmip'
}
async def test_import_existing_config(hass):
"""Test abort of an existing accesspoint from config."""
flow = config_flow.HomematicipCloudFlowHandler()
flow.hass = hass
MockConfigEntry(domain=const.DOMAIN, data={
hmipc.HMIPC_HAPID: 'ABC123',
}).add_to_hass(hass)
result = await flow.async_step_import({
hmipc.HMIPC_HAPID: 'ABC123',
hmipc.HMIPC_AUTHTOKEN: '123',
hmipc.HMIPC_NAME: 'hmip'
})
assert result['type'] == 'abort'

View File

@ -0,0 +1,113 @@
"""Test HomematicIP Cloud accesspoint."""
from unittest.mock import Mock, patch
from homeassistant.components.homematicip_cloud import hap as hmipc
from homeassistant.components.homematicip_cloud import const, errors
from tests.common import mock_coro
async def test_auth_setup(hass):
"""Test auth setup for client registration."""
config = {
const.HMIPC_HAPID: 'ABC123',
const.HMIPC_PIN: '123',
const.HMIPC_NAME: 'hmip',
}
hap = hmipc.HomematicipAuth(hass, config)
with patch.object(hap, 'get_auth', return_value=mock_coro()):
assert await hap.async_setup() is True
async def test_auth_setup_connection_error(hass):
"""Test auth setup connection error behaviour."""
config = {
const.HMIPC_HAPID: 'ABC123',
const.HMIPC_PIN: '123',
const.HMIPC_NAME: 'hmip',
}
hap = hmipc.HomematicipAuth(hass, config)
with patch.object(hap, 'get_auth',
side_effect=errors.HmipcConnectionError):
assert await hap.async_setup() is False
async def test_auth_auth_check_and_register(hass):
"""Test auth client registration."""
config = {
const.HMIPC_HAPID: 'ABC123',
const.HMIPC_PIN: '123',
const.HMIPC_NAME: 'hmip',
}
hap = hmipc.HomematicipAuth(hass, config)
hap.auth = Mock()
with patch.object(hap.auth, 'isRequestAcknowledged',
return_value=mock_coro()), \
patch.object(hap.auth, 'requestAuthToken',
return_value=mock_coro('ABC')), \
patch.object(hap.auth, 'confirmAuthToken',
return_value=mock_coro()):
assert await hap.async_checkbutton() is True
assert await hap.async_register() == 'ABC'
async def test_hap_setup_works(aioclient_mock):
"""Test a successful setup of a accesspoint."""
hass = Mock()
entry = Mock()
home = Mock()
entry.data = {
hmipc.HMIPC_HAPID: 'ABC123',
hmipc.HMIPC_AUTHTOKEN: '123',
hmipc.HMIPC_NAME: 'hmip',
}
hap = hmipc.HomematicipHAP(hass, entry)
with patch.object(hap, 'get_hap', return_value=mock_coro(home)):
assert await hap.async_setup() is True
assert hap.home is home
assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 5
assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \
(entry, 'binary_sensor')
async def test_hap_setup_connection_error():
"""Test a failed accesspoint setup."""
hass = Mock()
entry = Mock()
entry.data = {
hmipc.HMIPC_HAPID: 'ABC123',
hmipc.HMIPC_AUTHTOKEN: '123',
hmipc.HMIPC_NAME: 'hmip',
}
hap = hmipc.HomematicipHAP(hass, entry)
with patch.object(hap, 'get_hap',
side_effect=errors.HmipcConnectionError):
assert await hap.async_setup() is False
assert len(hass.async_add_job.mock_calls) == 0
assert len(hass.config_entries.flow.async_init.mock_calls) == 0
async def test_hap_reset_unloads_entry_if_setup():
"""Test calling reset while the entry has been setup."""
hass = Mock()
entry = Mock()
home = Mock()
entry.data = {
hmipc.HMIPC_HAPID: 'ABC123',
hmipc.HMIPC_AUTHTOKEN: '123',
hmipc.HMIPC_NAME: 'hmip',
}
hap = hmipc.HomematicipHAP(hass, entry)
with patch.object(hap, 'get_hap', return_value=mock_coro(home)):
assert await hap.async_setup() is True
assert hap.home is home
assert len(hass.services.async_register.mock_calls) == 0
assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 5
hass.config_entries.async_forward_entry_unload.return_value = \
mock_coro(True)
await hap.async_reset()
assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 5

View File

@ -0,0 +1,103 @@
"""Test HomematicIP Cloud setup process."""
from unittest.mock import patch
from homeassistant.setup import async_setup_component
from homeassistant.components import homematicip_cloud as hmipc
from tests.common import mock_coro, MockConfigEntry
async def test_config_with_accesspoint_passed_to_config_entry(hass):
"""Test that config for a accesspoint are loaded via config entry."""
with patch.object(hass, 'config_entries') as mock_config_entries, \
patch.object(hmipc, 'configured_haps', return_value=[]):
assert await async_setup_component(hass, hmipc.DOMAIN, {
hmipc.DOMAIN: {
hmipc.CONF_ACCESSPOINT: 'ABC123',
hmipc.CONF_AUTHTOKEN: '123',
hmipc.CONF_NAME: 'name',
}
}) is True
# Flow started for the access point
assert len(mock_config_entries.flow.mock_calls) == 2
async def test_config_already_registered_not_passed_to_config_entry(hass):
"""Test that an already registered accesspoint does not get imported."""
with patch.object(hass, 'config_entries') as mock_config_entries, \
patch.object(hmipc, 'configured_haps', return_value=['ABC123']):
assert await async_setup_component(hass, hmipc.DOMAIN, {
hmipc.DOMAIN: {
hmipc.CONF_ACCESSPOINT: 'ABC123',
hmipc.CONF_AUTHTOKEN: '123',
hmipc.CONF_NAME: 'name',
}
}) is True
# No flow started
assert len(mock_config_entries.flow.mock_calls) == 0
async def test_setup_entry_successful(hass):
"""Test setup entry is successful."""
entry = MockConfigEntry(domain=hmipc.DOMAIN, data={
hmipc.HMIPC_HAPID: 'ABC123',
hmipc.HMIPC_AUTHTOKEN: '123',
hmipc.HMIPC_NAME: 'hmip',
})
entry.add_to_hass(hass)
with patch.object(hmipc, 'HomematicipHAP') as mock_hap:
mock_hap.return_value.async_setup.return_value = mock_coro(True)
assert await async_setup_component(hass, hmipc.DOMAIN, {
hmipc.DOMAIN: {
hmipc.CONF_ACCESSPOINT: 'ABC123',
hmipc.CONF_AUTHTOKEN: '123',
hmipc.CONF_NAME: 'hmip',
}
}) is True
assert len(mock_hap.mock_calls) == 2
async def test_setup_defined_accesspoint(hass):
"""Test we initiate config entry for the accesspoint."""
with patch.object(hass, 'config_entries') as mock_config_entries, \
patch.object(hmipc, 'configured_haps', return_value=[]):
mock_config_entries.flow.async_init.return_value = mock_coro()
assert await async_setup_component(hass, hmipc.DOMAIN, {
hmipc.DOMAIN: {
hmipc.CONF_ACCESSPOINT: 'ABC123',
hmipc.CONF_AUTHTOKEN: '123',
hmipc.CONF_NAME: 'hmip',
}
}) is True
assert len(mock_config_entries.flow.mock_calls) == 1
assert mock_config_entries.flow.mock_calls[0][2]['data'] == {
hmipc.HMIPC_HAPID: 'ABC123',
hmipc.HMIPC_AUTHTOKEN: '123',
hmipc.HMIPC_NAME: 'hmip',
}
async def test_unload_entry(hass):
"""Test being able to unload an entry."""
entry = MockConfigEntry(domain=hmipc.DOMAIN, data={
hmipc.HMIPC_HAPID: 'ABC123',
hmipc.HMIPC_AUTHTOKEN: '123',
hmipc.HMIPC_NAME: 'hmip',
})
entry.add_to_hass(hass)
with patch.object(hmipc, 'HomematicipHAP') as mock_hap:
mock_hap.return_value.async_setup.return_value = mock_coro(True)
assert await async_setup_component(hass, hmipc.DOMAIN, {}) is True
assert len(mock_hap.return_value.mock_calls) == 1
mock_hap.return_value.async_reset.return_value = mock_coro(True)
assert await hmipc.async_unload_entry(hass, entry)
assert len(mock_hap.return_value.async_reset.mock_calls) == 1
assert hass.data[hmipc.DOMAIN] == {}