From 90639d33ab6b574abfec71b8ebbdd86cefff9081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 20 Jul 2017 15:20:00 +0200 Subject: [PATCH] Xiaomi gw support (#8555) * xiaomi support * xiaomi support * style * style * style * style * style * coveragerc * Update xiaomi.py * Update xiaomi.py * Update xiaomi.py * refactorization * refactorization * config validation * style * package * refactorization * refactorization * refactorization * HA integration --- .coveragerc | 4 +- .../components/binary_sensor/xiaomi.py | 316 ++++++++++++++++++ homeassistant/components/cover/xiaomi.py | 66 ++++ homeassistant/components/light/xiaomi.py | 103 ++++++ homeassistant/components/sensor/xiaomi.py | 77 +++++ homeassistant/components/switch/xiaomi.py | 111 ++++++ homeassistant/components/xiaomi.py | 195 +++++++++++ requirements_all.txt | 3 + 8 files changed, 874 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/binary_sensor/xiaomi.py create mode 100644 homeassistant/components/cover/xiaomi.py create mode 100755 homeassistant/components/light/xiaomi.py create mode 100644 homeassistant/components/sensor/xiaomi.py create mode 100644 homeassistant/components/switch/xiaomi.py create mode 100644 homeassistant/components/xiaomi.py diff --git a/.coveragerc b/.coveragerc index 2d1bff462b9..3ec0b119cb8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -193,6 +193,9 @@ omit = homeassistant/components/wink.py homeassistant/components/*/wink.py + homeassistant/components/xiaomi.py + homeassistant/components/*/xiaomi.py + homeassistant/components/zabbix.py homeassistant/components/*/zabbix.py @@ -274,7 +277,6 @@ omit = homeassistant/components/device_tracker/tplink.py homeassistant/components/device_tracker/trackr.py homeassistant/components/device_tracker/ubus.py - homeassistant/components/device_tracker/xiaomi.py homeassistant/components/downloader.py homeassistant/components/emoncms_history.py homeassistant/components/emulated_hue/upnp.py diff --git a/homeassistant/components/binary_sensor/xiaomi.py b/homeassistant/components/binary_sensor/xiaomi.py new file mode 100644 index 00000000000..14d2ef9b308 --- /dev/null +++ b/homeassistant/components/binary_sensor/xiaomi.py @@ -0,0 +1,316 @@ +"""Support for Xiaomi binary sensors.""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) + +_LOGGER = logging.getLogger(__name__) + +NO_CLOSE = 'no_close' +ATTR_OPEN_SINCE = 'Open since' + +MOTION = 'motion' +NO_MOTION = 'no_motion' +ATTR_NO_MOTION_SINCE = 'No motion since' + +DENSITY = 'density' +ATTR_DENSITY = 'Density' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Perform the setup for Xiaomi devices.""" + devices = [] + for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): + for device in gateway.devices['binary_sensor']: + model = device['model'] + if model == 'motion': + devices.append(XiaomiMotionSensor(device, hass, gateway)) + elif model == 'magnet': + devices.append(XiaomiDoorSensor(device, gateway)) + elif model == 'smoke': + devices.append(XiaomiSmokeSensor(device, gateway)) + elif model == 'natgas': + devices.append(XiaomiNatgasSensor(device, gateway)) + elif model == 'switch': + devices.append(XiaomiButton(device, 'Switch', 'status', + hass, gateway)) + elif model == 'sensor_switch.aq2': + devices.append(XiaomiButton(device, 'Switch', 'status', + hass, gateway)) + elif model == '86sw1': + devices.append(XiaomiButton(device, 'Wall Switch', 'channel_0', + hass, gateway)) + elif model == '86sw2': + devices.append(XiaomiButton(device, 'Wall Switch (Left)', + 'channel_0', hass, gateway)) + devices.append(XiaomiButton(device, 'Wall Switch (Right)', + 'channel_1', hass, gateway)) + devices.append(XiaomiButton(device, 'Wall Switch (Both)', + 'dual_channel', hass, gateway)) + elif model == 'cube': + devices.append(XiaomiCube(device, hass, gateway)) + add_devices(devices) + + +class XiaomiBinarySensor(XiaomiDevice, BinarySensorDevice): + """Representation of a base XiaomiBinarySensor.""" + + def __init__(self, device, name, xiaomi_hub): + """Initialize the XiaomiSmokeSensor.""" + self._data_key = None + self._device_class = None + self._should_poll = False + self._density = 0 + XiaomiDevice.__init__(self, device, name, xiaomi_hub) + + @property + def should_poll(self): + """Return True if entity has to be polled for state.""" + return self._should_poll + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_class(self): + """Return the class of binary sensor.""" + return self._device_class + + def update(self): + """Update the sensor state.""" + _LOGGER.debug('Updating xiaomi sensor by polling') + self._get_from_hub(self._sid) + + +class XiaomiNatgasSensor(XiaomiBinarySensor): + """Representation of a XiaomiNatgasSensor.""" + + def __init__(self, device, xiaomi_hub): + """Initialize the XiaomiSmokeSensor.""" + self._data_key = 'alarm' + self._density = None + self._device_class = 'gas' + XiaomiBinarySensor.__init__(self, device, 'Natgas Sensor', xiaomi_hub) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {ATTR_DENSITY: self._density} + attrs.update(super().device_state_attributes) + return attrs + + def parse_data(self, data): + """Parse data sent by gateway.""" + if DENSITY in data: + self._density = int(data.get(DENSITY)) + + value = data.get(self._data_key) + if value is None: + return False + + if value == '1': + if self._state: + return False + self._state = True + return True + elif value == '0': + if self._state: + self._state = False + return True + return False + + +class XiaomiMotionSensor(XiaomiBinarySensor): + """Representation of a XiaomiMotionSensor.""" + + def __init__(self, device, hass, xiaomi_hub): + """Initialize the XiaomiMotionSensor.""" + self._hass = hass + self._data_key = 'status' + self._no_motion_since = 0 + self._device_class = 'motion' + XiaomiBinarySensor.__init__(self, device, 'Motion Sensor', xiaomi_hub) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {ATTR_NO_MOTION_SINCE: self._no_motion_since} + attrs.update(super().device_state_attributes) + return attrs + + def parse_data(self, data): + """Parse data sent by gateway.""" + self._should_poll = False + if NO_MOTION in data: # handle push from the hub + self._no_motion_since = data[NO_MOTION] + self._state = False + return True + + value = data.get(self._data_key) + if value is None: + return False + + if value == MOTION: + self._should_poll = True + if self.entity_id is not None: + self._hass.bus.fire('motion', { + 'entity_id': self.entity_id + }) + + self._no_motion_since = 0 + if self._state: + return False + self._state = True + return True + elif value == NO_MOTION: + if not self._state: + return False + self._state = False + return True + + +class XiaomiDoorSensor(XiaomiBinarySensor): + """Representation of a XiaomiDoorSensor.""" + + def __init__(self, device, xiaomi_hub): + """Initialize the XiaomiDoorSensor.""" + self._data_key = 'status' + self._open_since = 0 + self._device_class = 'opening' + XiaomiBinarySensor.__init__(self, device, 'Door Window Sensor', + xiaomi_hub) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {ATTR_OPEN_SINCE: self._open_since} + attrs.update(super().device_state_attributes) + return attrs + + def parse_data(self, data): + """Parse data sent by gateway.""" + self._should_poll = False + if NO_CLOSE in data: # handle push from the hub + self._open_since = data[NO_CLOSE] + return True + + value = data.get(self._data_key) + if value is None: + return False + + if value == 'open': + self._should_poll = True + if self._state: + return False + self._state = True + return True + elif value == 'close': + self._open_since = 0 + if self._state: + self._state = False + return True + return False + + +class XiaomiSmokeSensor(XiaomiBinarySensor): + """Representation of a XiaomiSmokeSensor.""" + + def __init__(self, device, xiaomi_hub): + """Initialize the XiaomiSmokeSensor.""" + self._data_key = 'alarm' + self._density = 0 + self._device_class = 'smoke' + XiaomiBinarySensor.__init__(self, device, 'Smoke Sensor', xiaomi_hub) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {ATTR_DENSITY: self._density} + attrs.update(super().device_state_attributes) + return attrs + + def parse_data(self, data): + """Parse data sent by gateway.""" + if DENSITY in data: + self._density = int(data.get(DENSITY)) + value = data.get(self._data_key) + if value is None: + return False + + if value == '1': + if self._state: + return False + self._state = True + return True + elif value == '0': + if self._state: + self._state = False + return True + return False + + +class XiaomiButton(XiaomiBinarySensor): + """Representation of a Xiaomi Button.""" + + def __init__(self, device, name, data_key, hass, xiaomi_hub): + """Initialize the XiaomiButton.""" + self._hass = hass + self._data_key = data_key + XiaomiBinarySensor.__init__(self, device, name, xiaomi_hub) + + def parse_data(self, data): + """Parse data sent by gateway.""" + value = data.get(self._data_key) + if value is None: + return False + + if value == 'long_click_press': + self._state = True + click_type = 'long_click_press' + elif value == 'long_click_release': + self._state = False + click_type = 'hold' + elif value == 'click': + click_type = 'single' + elif value == 'double_click': + click_type = 'double' + elif value == 'both_click': + click_type = 'both' + else: + return False + + self._hass.bus.fire('click', { + 'entity_id': self.entity_id, + 'click_type': click_type + }) + if value in ['long_click_press', 'long_click_release']: + return True + return False + + +class XiaomiCube(XiaomiBinarySensor): + """Representation of a Xiaomi Cube.""" + + def __init__(self, device, hass, xiaomi_hub): + """Initialize the Xiaomi Cube.""" + self._hass = hass + self._state = False + XiaomiBinarySensor.__init__(self, device, 'Cube', xiaomi_hub) + + def parse_data(self, data): + """Parse data sent by gateway.""" + if 'status' in data: + self._hass.bus.fire('cube_action', { + 'entity_id': self.entity_id, + 'action_type': data['status'] + }) + + if 'rotate' in data: + self._hass.bus.fire('cube_action', { + 'entity_id': self.entity_id, + 'action_type': 'rotate', + 'action_value': float(data['rotate'].replace(",", ".")) + }) + return False diff --git a/homeassistant/components/cover/xiaomi.py b/homeassistant/components/cover/xiaomi.py new file mode 100644 index 00000000000..7e3b0b7044d --- /dev/null +++ b/homeassistant/components/cover/xiaomi.py @@ -0,0 +1,66 @@ +"""Support for Xiaomi curtain.""" +import logging + +from homeassistant.components.cover import CoverDevice +from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) + +_LOGGER = logging.getLogger(__name__) + +ATTR_CURTAIN_LEVEL = 'curtain_level' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Perform the setup for Xiaomi devices.""" + devices = [] + for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): + for device in gateway.devices['cover']: + model = device['model'] + if model == 'curtain': + devices.append(XiaomiGenericCover(device, "Curtain", + {'status': 'status', + 'pos': 'curtain_level'}, + gateway)) + add_devices(devices) + + +class XiaomiGenericCover(XiaomiDevice, CoverDevice): + """Representation of a XiaomiPlug.""" + + def __init__(self, device, name, data_key, xiaomi_hub): + """Initialize the XiaomiPlug.""" + self._data_key = data_key + self._pos = 0 + XiaomiDevice.__init__(self, device, name, xiaomi_hub) + + @property + def current_cover_position(self): + """Return the current position of the cover.""" + return self._pos + + @property + def is_closed(self): + """Return if the cover is closed.""" + return self.current_cover_position < 0 + + def close_cover(self, **kwargs): + """Close the cover.""" + self._write_to_hub(self._sid, self._data_key['status'], 'close') + + def open_cover(self, **kwargs): + """Open the cover.""" + self._write_to_hub(self._sid, self._data_key['status'], 'open') + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self._write_to_hub(self._sid, self._data_key['status'], 'stop') + + def set_cover_position(self, position, **kwargs): + """Move the cover to a specific position.""" + self._write_to_hub(self._sid, self._data_key['pos'], str(position)) + + def parse_data(self, data): + """Parse data sent by gateway.""" + if ATTR_CURTAIN_LEVEL in data: + self._pos = int(data[ATTR_CURTAIN_LEVEL]) + return True + return False diff --git a/homeassistant/components/light/xiaomi.py b/homeassistant/components/light/xiaomi.py new file mode 100755 index 00000000000..d8a70b726f4 --- /dev/null +++ b/homeassistant/components/light/xiaomi.py @@ -0,0 +1,103 @@ +"""Support for Xiaomi Gateway Light.""" +import logging +import struct +import binascii +from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_RGB_COLOR, + SUPPORT_BRIGHTNESS, + SUPPORT_RGB_COLOR, Light) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Perform the setup for Xiaomi devices.""" + devices = [] + for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): + for device in gateway.devices['light']: + model = device['model'] + if model == 'gateway': + devices.append(XiaomiGatewayLight(device, 'Gateway Light', + gateway)) + add_devices(devices) + + +class XiaomiGatewayLight(XiaomiDevice, Light): + """Representation of a XiaomiGatewayLight.""" + + def __init__(self, device, name, xiaomi_hub): + """Initialize the XiaomiGatewayLight.""" + self._data_key = 'rgb' + self._rgb = (255, 255, 255) + self._brightness = 180 + + XiaomiDevice.__init__(self, device, name, xiaomi_hub) + + @property + def is_on(self): + """Return true if it is on.""" + return self._state + + def parse_data(self, data): + """Parse data sent by gateway.""" + value = data.get(self._data_key) + if value is None: + return False + + if value == 0: + if self._state: + self._state = False + return True + + rgbhexstr = "%x" % value + if len(rgbhexstr) == 7: + rgbhexstr = '0' + rgbhexstr + elif len(rgbhexstr) != 8: + _LOGGER.error('Light RGB data error.' + ' Must be 8 characters. Received: %s', rgbhexstr) + return False + + rgbhex = bytes.fromhex(rgbhexstr) + rgba = struct.unpack('BBBB', rgbhex) + brightness = rgba[0] + rgb = rgba[1:] + + self._brightness = int(255 * brightness / 100) + self._rgb = rgb + self._state = True + return True + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def rgb_color(self): + """Return the RBG color value.""" + return self._rgb + + @property + def supported_features(self): + """Return the supported features.""" + return SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR + + def turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_RGB_COLOR in kwargs: + self._rgb = kwargs[ATTR_RGB_COLOR] + + if ATTR_BRIGHTNESS in kwargs: + self._brightness = int(100 * kwargs[ATTR_BRIGHTNESS] / 255) + + rgba = (self._brightness,) + self._rgb + rgbhex = binascii.hexlify(struct.pack('BBBB', *rgba)).decode("ASCII") + rgbhex = int(rgbhex, 16) + + if self._write_to_hub(self._sid, **{self._data_key: rgbhex}): + self._state = True + + def turn_off(self, **kwargs): + """Turn the light off.""" + if self._write_to_hub(self._sid, **{self._data_key: 0}): + self._state = False diff --git a/homeassistant/components/sensor/xiaomi.py b/homeassistant/components/sensor/xiaomi.py new file mode 100644 index 00000000000..b1fcd8beb1f --- /dev/null +++ b/homeassistant/components/sensor/xiaomi.py @@ -0,0 +1,77 @@ +"""Support for Xiaomi sensors.""" +import logging + +from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.const import TEMP_CELSIUS + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Perform the setup for Xiaomi devices.""" + devices = [] + for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): + for device in gateway.devices['sensor']: + if device['model'] == 'sensor_ht': + devices.append(XiaomiSensor(device, 'Temperature', + 'temperature', gateway)) + devices.append(XiaomiSensor(device, 'Humidity', + 'humidity', gateway)) + if device['model'] == 'weather.v1': + devices.append(XiaomiSensor(device, 'Temperature', + 'temperature', gateway)) + devices.append(XiaomiSensor(device, 'Humidity', + 'humidity', gateway)) + devices.append(XiaomiSensor(device, 'Pressure', + 'pressure', gateway)) + elif device['model'] == 'gateway': + devices.append(XiaomiSensor(device, 'Illumination', + 'illumination', gateway)) + add_devices(devices) + + +class XiaomiSensor(XiaomiDevice): + """Representation of a XiaomiSensor.""" + + def __init__(self, device, name, data_key, xiaomi_hub): + """Initialize the XiaomiSensor.""" + self._data_key = data_key + XiaomiDevice.__init__(self, device, name, xiaomi_hub) + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + if self._data_key == 'temperature': + return TEMP_CELSIUS + elif self._data_key == 'humidity': + return '%' + elif self._data_key == 'illumination': + return 'lm' + elif self._data_key == 'pressure': + return 'hPa' + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def parse_data(self, data): + """Parse data sent by gateway.""" + value = data.get(self._data_key) + if value is None: + return False + value = float(value) + if self._data_key == 'temperature' and value == 10000: + return False + elif self._data_key == 'humidity' and value == 0: + return False + elif self._data_key == 'illumination' and value == 0: + return False + elif self._data_key == 'pressure' and value == 0: + return False + if self._data_key in ['temperature', 'humidity']: + value /= 100 + elif self._data_key in ['illumination']: + value = max(value - 300, 0) + self._state = round(value, 2) + return True diff --git a/homeassistant/components/switch/xiaomi.py b/homeassistant/components/switch/xiaomi.py new file mode 100644 index 00000000000..fa472136bb5 --- /dev/null +++ b/homeassistant/components/switch/xiaomi.py @@ -0,0 +1,111 @@ +"""Support for Xiaomi binary sensors.""" +import logging + +from homeassistant.components.switch import SwitchDevice +from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) + +_LOGGER = logging.getLogger(__name__) + +ATTR_LOAD_POWER = 'Load power' # Load power in watts (W) +ATTR_POWER_CONSUMED = 'Power consumed' +ATTR_IN_USE = 'In use' +LOAD_POWER = 'load_power' +POWER_CONSUMED = 'power_consumed' +IN_USE = 'inuse' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Perform the setup for Xiaomi devices.""" + devices = [] + for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): + for device in gateway.devices['switch']: + model = device['model'] + if model == 'plug': + devices.append(XiaomiGenericSwitch(device, "Plug", 'status', + True, gateway)) + elif model == 'ctrl_neutral1': + devices.append(XiaomiGenericSwitch(device, 'Wall Switch', + 'channel_0', + False, gateway)) + elif model == 'ctrl_neutral2': + devices.append(XiaomiGenericSwitch(device, 'Wall Switch Left', + 'channel_0', + False, gateway)) + devices.append(XiaomiGenericSwitch(device, 'Wall Switch Right', + 'channel_1', + False, gateway)) + elif model == '86plug': + devices.append(XiaomiGenericSwitch(device, 'Wall Plug', + 'status', True, gateway)) + add_devices(devices) + + +class XiaomiGenericSwitch(XiaomiDevice, SwitchDevice): + """Representation of a XiaomiPlug.""" + + def __init__(self, device, name, data_key, supports_power_consumption, + xiaomi_hub): + """Initialize the XiaomiPlug.""" + self._data_key = data_key + self._in_use = None + self._load_power = None + self._power_consumed = None + self._supports_power_consumption = supports_power_consumption + XiaomiDevice.__init__(self, device, name, xiaomi_hub) + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + if self._data_key == 'status': + return 'mdi:power-plug' + return 'mdi:power-socket' + + @property + def is_on(self): + """Return true if it is on.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._supports_power_consumption: + attrs = {ATTR_IN_USE: self._in_use, + ATTR_LOAD_POWER: self._load_power, + ATTR_POWER_CONSUMED: self._power_consumed} + else: + attrs = {} + attrs.update(super().device_state_attributes) + return attrs + + def turn_on(self, **kwargs): + """Turn the switch on.""" + if self._write_to_hub(self._sid, **{self._data_key: 'on'}): + self._state = True + self.schedule_update_ha_state() + + def turn_off(self): + """Turn the switch off.""" + if self._write_to_hub(self._sid, **{self._data_key: 'off'}): + self._state = False + self.schedule_update_ha_state() + + def parse_data(self, data): + """Parse data sent by gateway.""" + if IN_USE in data: + self._in_use = int(data[IN_USE]) + if not self._in_use: + self._load_power = 0 + if POWER_CONSUMED in data: + self._power_consumed = round(float(data[POWER_CONSUMED]), 2) + if LOAD_POWER in data: + self._load_power = round(float(data[LOAD_POWER]), 2) + + value = data.get(self._data_key) + if value is None: + return False + + state = value == 'on' + if self._state == state: + return False + self._state = state + return True diff --git a/homeassistant/components/xiaomi.py b/homeassistant/components/xiaomi.py new file mode 100644 index 00000000000..e4a9698c71f --- /dev/null +++ b/homeassistant/components/xiaomi.py @@ -0,0 +1,195 @@ +"""Support for Xiaomi Gateways.""" +import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.helpers.entity import Entity +from homeassistant.const import (ATTR_BATTERY_LEVEL, EVENT_HOMEASSISTANT_STOP, + CONF_MAC) + + +REQUIREMENTS = ['https://github.com/Danielhiversen/PyXiaomiGateway/archive/' + '877faec36e1bfa4177cae2a0d4f49570af083e1d.zip#' + 'PyXiaomiGateway==0.1.0'] + +ATTR_GW_SID = 'gw_sid' +ATTR_RINGTONE_ID = 'ringtone_id' +ATTR_RINGTONE_VOL = 'ringtone_vol' +CONF_DISCOVERY_RETRY = 'discovery_retry' +CONF_GATEWAYS = 'gateways' +CONF_INTERFACE = 'interface' +DOMAIN = 'xiaomi' +PY_XIAOMI_GATEWAY = "xiaomi_gw" + + +def _validate_conf(config): + """Validate a list of devices definitions.""" + res_config = [] + for gw_conf in config: + res_gw_conf = {'sid': gw_conf.get(CONF_MAC)} + if res_gw_conf['sid'] is not None: + res_gw_conf['sid'] = res_gw_conf['sid'].replace(":", "").lower() + if len(res_gw_conf['sid']) != 12: + raise vol.Invalid('Invalid mac address', gw_conf.get(CONF_MAC)) + key = gw_conf.get('key') + if key is None: + _LOGGER.warning( + 'Gateway Key is not provided.' + ' Controlling gateway device will not be possible.') + elif len(key) != 16: + raise vol.Invalid('Invalid key %s.' + ' Key must be 16 characters', key) + res_gw_conf['key'] = key + res_config.append(res_gw_conf) + return res_config + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_GATEWAYS, default=[{CONF_MAC: None, "key": None}]): + vol.All(cv.ensure_list, _validate_conf), + vol.Optional(CONF_INTERFACE, default='any'): cv.string, + vol.Optional(CONF_DISCOVERY_RETRY, default=3): cv.positive_int + }) +}, extra=vol.ALLOW_EXTRA) + +_LOGGER = logging.getLogger(__name__) + + +def setup(hass, config): + """Set up the Xiaomi component.""" + gateways = config[DOMAIN][CONF_GATEWAYS] + interface = config[DOMAIN][CONF_INTERFACE] + discovery_retry = config[DOMAIN][CONF_DISCOVERY_RETRY] + + from PyXiaomiGateway import PyXiaomiGateway + hass.data[PY_XIAOMI_GATEWAY] = PyXiaomiGateway(hass.add_job, gateways, + interface) + + _LOGGER.debug("Expecting %s gateways", len(gateways)) + for _ in range(discovery_retry): + _LOGGER.info('Discovering Xiaomi Gateways (Try %s)', _ + 1) + hass.data[PY_XIAOMI_GATEWAY].discover_gateways() + if len(hass.data[PY_XIAOMI_GATEWAY].gateways) >= len(gateways): + break + + if not hass.data[PY_XIAOMI_GATEWAY].gateways: + _LOGGER.error("No gateway discovered") + return False + hass.data[PY_XIAOMI_GATEWAY].listen() + _LOGGER.debug("Listening for broadcast") + + for component in ['binary_sensor', 'sensor', 'switch', 'light', 'cover']: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + def stop_xiaomi(event): + """Stop Xiaomi Socket.""" + _LOGGER.info("Shutting down Xiaomi Hub.") + hass.data[PY_XIAOMI_GATEWAY].stop_listen() + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_xiaomi) + + def play_ringtone_service(call): + """Service to play ringtone through Gateway.""" + if call.data.get(ATTR_RINGTONE_ID) is None \ + or call.data.get(ATTR_GW_SID) is None: + _LOGGER.error("Mandatory parameters is not specified.") + return + + ring_id = int(call.data.get(ATTR_RINGTONE_ID)) + if ring_id in [9, 14-19]: + _LOGGER.error('Specified mid: %s is not defined in gateway.', + ring_id) + return + + ring_vol = call.data.get(ATTR_RINGTONE_VOL) + if ring_vol is None: + ringtone = {'mid': ring_id} + else: + ringtone = {'mid': ring_id, 'vol': int(ring_vol)} + + gw_sid = call.data.get(ATTR_GW_SID) + + for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): + if gateway.sid == gw_sid: + gateway.write_to_hub(gateway.sid, **ringtone) + break + else: + _LOGGER.error('Unknown gateway sid: %s was specified.', gw_sid) + + def stop_ringtone_service(call): + """Service to stop playing ringtone on Gateway.""" + gw_sid = call.data.get(ATTR_GW_SID) + if gw_sid is None: + _LOGGER.error("Mandatory parameter (%s) is not specified.", + ATTR_GW_SID) + return + + for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): + if gateway.sid == gw_sid: + ringtone = {'mid': 10000} + gateway.write_to_hub(gateway.sid, **ringtone) + break + else: + _LOGGER.error('Unknown gateway sid: %s was specified.', gw_sid) + + hass.services.async_register(DOMAIN, 'play_ringtone', + play_ringtone_service, + description=None, schema=None) + hass.services.async_register(DOMAIN, 'stop_ringtone', + stop_ringtone_service, + description=None, schema=None) + return True + + +class XiaomiDevice(Entity): + """Representation a base Xiaomi device.""" + + def __init__(self, device, name, xiaomi_hub): + """Initialize the xiaomi device.""" + self._state = None + self._sid = device['sid'] + self._name = '{}_{}'.format(name, self._sid) + self._write_to_hub = xiaomi_hub.write_to_hub + self._get_from_hub = xiaomi_hub.get_from_hub + xiaomi_hub.callbacks[self._sid].append(self.push_data) + self._device_state_attributes = {} + self.parse_data(device['data']) + self.parse_voltage(device['data']) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """Poll update device status.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._device_state_attributes + + def push_data(self, data): + """Push from Hub.""" + _LOGGER.debug("PUSH >> %s: %s", self, data) + if self.parse_data(data) or self.parse_voltage(data): + self.schedule_update_ha_state() + + def parse_voltage(self, data): + """Parse battery level data sent by gateway.""" + if 'voltage' not in data: + return False + max_volt = 3300 + min_volt = 2800 + voltage = data['voltage'] + voltage = min(voltage, max_volt) + voltage = max(voltage, min_volt) + percent = ((voltage - min_volt) / (max_volt - min_volt)) * 100 + self._device_state_attributes[ATTR_BATTERY_LEVEL] = round(percent, 1) + return True + + def parse_data(self, data): + """Parse data sent by gateway.""" + raise NotImplementedError() diff --git a/requirements_all.txt b/requirements_all.txt index 1adc4778eba..8199348b824 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,6 +276,9 @@ holidays==0.8.1 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a +# homeassistant.components.xiaomi +https://github.com/Danielhiversen/PyXiaomiGateway/archive/877faec36e1bfa4177cae2a0d4f49570af083e1d.zip#PyXiaomiGateway==0.1.0 + # homeassistant.components.sensor.dht # https://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.0