2018-03-18 15:57:53 +00:00
|
|
|
"""
|
|
|
|
Support for HomematicIP components.
|
|
|
|
|
|
|
|
For more details about this component, please refer to the documentation at
|
2018-03-23 18:05:02 +00:00
|
|
|
https://home-assistant.io/components/homematicip_cloud/
|
2018-03-18 15:57:53 +00:00
|
|
|
"""
|
|
|
|
|
2018-04-25 19:57:44 +00:00
|
|
|
import asyncio
|
2018-03-18 15:57:53 +00:00
|
|
|
import logging
|
2018-05-01 15:01:13 +00:00
|
|
|
|
2018-03-18 15:57:53 +00:00
|
|
|
import voluptuous as vol
|
2018-03-23 18:05:02 +00:00
|
|
|
|
2018-05-01 15:01:13 +00:00
|
|
|
import homeassistant.helpers.config_validation as cv
|
2018-04-25 19:57:44 +00:00
|
|
|
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
|
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
|
|
from homeassistant.helpers.discovery import async_load_platform
|
2018-03-18 15:57:53 +00:00
|
|
|
from homeassistant.helpers.entity import Entity
|
2018-05-01 15:01:13 +00:00
|
|
|
from homeassistant.core import callback
|
2018-03-18 15:57:53 +00:00
|
|
|
|
2018-05-26 14:03:53 +00:00
|
|
|
REQUIREMENTS = ['homematicip==0.9.4']
|
2018-03-18 15:57:53 +00:00
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
DOMAIN = 'homematicip_cloud'
|
|
|
|
|
2018-04-25 19:57:44 +00:00
|
|
|
COMPONENTS = [
|
2018-05-08 07:57:51 +00:00
|
|
|
'sensor',
|
|
|
|
'binary_sensor',
|
|
|
|
'switch',
|
2018-05-26 14:03:53 +00:00
|
|
|
'light',
|
|
|
|
'climate',
|
2018-04-25 19:57:44 +00:00
|
|
|
]
|
|
|
|
|
2018-03-18 15:57:53 +00:00
|
|
|
CONF_NAME = 'name'
|
|
|
|
CONF_ACCESSPOINT = 'accesspoint'
|
|
|
|
CONF_AUTHTOKEN = 'authtoken'
|
|
|
|
|
|
|
|
CONFIG_SCHEMA = vol.Schema({
|
2018-04-25 19:57:44 +00:00
|
|
|
vol.Optional(DOMAIN, default=[]): vol.All(cv.ensure_list, [vol.Schema({
|
|
|
|
vol.Optional(CONF_NAME): vol.Any(cv.string),
|
2018-03-18 15:57:53 +00:00
|
|
|
vol.Required(CONF_ACCESSPOINT): cv.string,
|
|
|
|
vol.Required(CONF_AUTHTOKEN): cv.string,
|
2018-04-25 19:57:44 +00:00
|
|
|
})]),
|
2018-03-18 15:57:53 +00:00
|
|
|
}, extra=vol.ALLOW_EXTRA)
|
|
|
|
|
2018-04-25 19:57:44 +00:00
|
|
|
HMIP_ACCESS_POINT = 'Access Point'
|
|
|
|
HMIP_HUB = 'HmIP-HUB'
|
2018-03-18 15:57:53 +00:00
|
|
|
|
|
|
|
ATTR_HOME_ID = 'home_id'
|
2018-04-25 19:57:44 +00:00
|
|
|
ATTR_HOME_NAME = 'home_name'
|
2018-03-18 15:57:53 +00:00
|
|
|
ATTR_DEVICE_ID = 'device_id'
|
|
|
|
ATTR_DEVICE_LABEL = 'device_label'
|
|
|
|
ATTR_STATUS_UPDATE = 'status_update'
|
|
|
|
ATTR_FIRMWARE_STATE = 'firmware_state'
|
2018-04-25 19:57:44 +00:00
|
|
|
ATTR_UNREACHABLE = 'unreachable'
|
2018-03-18 15:57:53 +00:00
|
|
|
ATTR_LOW_BATTERY = 'low_battery'
|
2018-04-25 19:57:44 +00:00
|
|
|
ATTR_MODEL_TYPE = 'model_type'
|
|
|
|
ATTR_GROUP_TYPE = 'group_type'
|
|
|
|
ATTR_DEVICE_RSSI = 'device_rssi'
|
|
|
|
ATTR_DUTY_CYCLE = 'duty_cycle'
|
|
|
|
ATTR_CONNECTED = 'connected'
|
2018-03-18 15:57:53 +00:00
|
|
|
ATTR_SABOTAGE = 'sabotage'
|
2018-04-25 19:57:44 +00:00
|
|
|
ATTR_OPERATION_LOCK = 'operation_lock'
|
2018-03-18 15:57:53 +00:00
|
|
|
|
|
|
|
|
2018-04-25 19:57:44 +00:00
|
|
|
async def async_setup(hass, config):
|
2018-03-18 15:57:53 +00:00
|
|
|
"""Set up the HomematicIP component."""
|
2018-04-25 19:57:44 +00:00
|
|
|
from homematicip.base.base_connection import HmipConnectionError
|
2018-03-23 18:05:02 +00:00
|
|
|
|
2018-03-18 15:57:53 +00:00
|
|
|
hass.data.setdefault(DOMAIN, {})
|
|
|
|
accesspoints = config.get(DOMAIN, [])
|
2018-04-25 19:57:44 +00:00
|
|
|
for conf in accesspoints:
|
|
|
|
_websession = async_get_clientsession(hass)
|
|
|
|
_hmip = HomematicipConnector(hass, conf, _websession)
|
2018-03-18 15:57:53 +00:00
|
|
|
try:
|
2018-04-25 19:57:44 +00:00
|
|
|
await _hmip.init()
|
|
|
|
except HmipConnectionError:
|
|
|
|
_LOGGER.error('Failed to connect to the HomematicIP server, %s.',
|
|
|
|
conf.get(CONF_ACCESSPOINT))
|
2018-03-18 15:57:53 +00:00
|
|
|
return False
|
|
|
|
|
2018-04-25 19:57:44 +00:00
|
|
|
home = _hmip.home
|
|
|
|
home.name = conf.get(CONF_NAME)
|
|
|
|
home.label = HMIP_ACCESS_POINT
|
|
|
|
home.modelType = HMIP_HUB
|
2018-03-23 18:05:02 +00:00
|
|
|
|
2018-04-25 19:57:44 +00:00
|
|
|
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())
|
2018-03-18 15:57:53 +00:00
|
|
|
return True
|
|
|
|
|
|
|
|
|
2018-04-25 19:57:44 +00:00
|
|
|
class HomematicipConnector:
|
|
|
|
"""Manages HomematicIP http and websocket connection."""
|
|
|
|
|
|
|
|
def __init__(self, hass, config, websession):
|
|
|
|
"""Initialize HomematicIP cloud connection."""
|
|
|
|
from homematicip.async.home import AsyncHome
|
2018-05-01 15:01:13 +00:00
|
|
|
|
2018-04-25 19:57:44 +00:00
|
|
|
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)
|
|
|
|
|
2018-05-01 15:01:13 +00:00
|
|
|
self.home.on_update(self.async_update)
|
|
|
|
self._accesspoint_connected = True
|
|
|
|
|
2018-04-25 19:57:44 +00:00
|
|
|
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()
|
|
|
|
|
2018-05-01 15:01:13 +00:00
|
|
|
@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()
|
|
|
|
|
2018-04-25 19:57:44 +00:00
|
|
|
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.")
|
|
|
|
|
|
|
|
|
2018-03-18 15:57:53 +00:00
|
|
|
class HomematicipGenericDevice(Entity):
|
|
|
|
"""Representation of an HomematicIP generic device."""
|
|
|
|
|
2018-04-25 19:57:44 +00:00
|
|
|
def __init__(self, home, device, post=None):
|
2018-03-18 15:57:53 +00:00
|
|
|
"""Initialize the generic device."""
|
|
|
|
self._home = home
|
|
|
|
self._device = device
|
2018-04-25 19:57:44 +00:00
|
|
|
self.post = post
|
|
|
|
_LOGGER.info('Setting up %s (%s)', self.name,
|
|
|
|
self._device.modelType)
|
2018-03-23 18:05:02 +00:00
|
|
|
|
|
|
|
async def async_added_to_hass(self):
|
|
|
|
"""Register callbacks."""
|
2018-04-25 19:57:44 +00:00
|
|
|
self._device.on_update(self._device_changed)
|
2018-03-18 15:57:53 +00:00
|
|
|
|
2018-04-25 19:57:44 +00:00
|
|
|
def _device_changed(self, json, **kwargs):
|
2018-03-18 15:57:53 +00:00
|
|
|
"""Handle device state changes."""
|
2018-04-25 19:57:44 +00:00
|
|
|
_LOGGER.debug('Event %s (%s)', self.name, self._device.modelType)
|
|
|
|
self.async_schedule_update_ha_state()
|
2018-03-18 15:57:53 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def name(self):
|
|
|
|
"""Return the name of the generic device."""
|
2018-04-25 19:57:44 +00:00
|
|
|
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
|
2018-03-18 15:57:53 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def should_poll(self):
|
|
|
|
"""No polling needed."""
|
|
|
|
return False
|
|
|
|
|
|
|
|
@property
|
|
|
|
def available(self):
|
|
|
|
"""Device available."""
|
|
|
|
return not self._device.unreach
|
|
|
|
|
2018-04-25 19:57:44 +00:00
|
|
|
@property
|
|
|
|
def device_state_attributes(self):
|
2018-03-18 15:57:53 +00:00
|
|
|
"""Return the state attributes of the generic device."""
|
|
|
|
return {
|
|
|
|
ATTR_LOW_BATTERY: self._device.lowBat,
|
2018-04-25 19:57:44 +00:00
|
|
|
ATTR_MODEL_TYPE: self._device.modelType
|
2018-03-18 15:57:53 +00:00
|
|
|
}
|