From 49c7b422f2a4f2c89c368a94b67c249b1cae044c Mon Sep 17 00:00:00 2001 From: Conrad Juhl Andersen Date: Tue, 6 Feb 2018 19:47:24 +0100 Subject: [PATCH] Add Xiaomi Universal IR Remote (Chuangmi IR) (#11891) * First version of remote xiaomi-miio * added to coveragerc * fixed pylint error * misc fixes and input validation * address syssi's requests except device and async_service_handler * forgot to run linter * implemented async_service_handler * fixed delay == None, honor timeout given by user, pythonic compare of None * Added some whitespace for readability, added error message to turn_on and turn_off, fixed services.yaml examples * fixed pylint errors * readd pass for readability * fixed small stuff * Use RemoteDevice, Make send_command non-async * Ready code for next version of python-miio (Support for pronto hex codes) * cast command_optional to int, better input validation, fixed index out of bounds error. * revert code now in python-miio * ready for python-miio 0.3.5 * Removed unneccary return statements * require 0.3.5 * Rebase and update requirements_all.txt --- .coveragerc | 1 + homeassistant/components/remote/services.yaml | 13 + .../components/remote/xiaomi_miio.py | 255 ++++++++++++++++++ requirements_all.txt | 1 + 4 files changed, 270 insertions(+) create mode 100755 homeassistant/components/remote/xiaomi_miio.py diff --git a/.coveragerc b/.coveragerc index e30e3025d16..4b19519038f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -510,6 +510,7 @@ omit = homeassistant/components/remember_the_milk/__init__.py homeassistant/components/remote/harmony.py homeassistant/components/remote/itach.py + homeassistant/components/remote/xiaomi_miio.py homeassistant/components/scene/hunterdouglas_powerview.py homeassistant/components/scene/lifx_cloud.py homeassistant/components/sensor/airvisual.py diff --git a/homeassistant/components/remote/services.yaml b/homeassistant/components/remote/services.yaml index 2a1deebdc7b..25ad626f96d 100644 --- a/homeassistant/components/remote/services.yaml +++ b/homeassistant/components/remote/services.yaml @@ -49,3 +49,16 @@ harmony_sync: entity_id: description: Name(s) of entities to sync. example: 'remote.family_room' + +xiaomi_miio_learn_command: + description: 'Learn an IR command, press "Call Service", point the remote at the IR device, and the learned command will be shown as a notification in Overview.' + fields: + entity_id: + description: 'Name of the entity to learn command from.' + example: 'remote.xiaomi_miio' + slot: + description: 'Define the slot used to save the IR command (Value from 1 to 1000000)' + example: '1' + timeout: + description: 'Define the timeout in seconds, before which the command must be learned.' + example: '30' diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py new file mode 100755 index 00000000000..aa05246c9cd --- /dev/null +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -0,0 +1,255 @@ +""" +Support for the Xiaomi IR Remote (Chuangmi IR). + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/remote.xiaomi_miio/ +""" +import asyncio +import logging +import time + +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.remote import ( + PLATFORM_SCHEMA, DOMAIN, ATTR_NUM_REPEATS, ATTR_DELAY_SECS, + DEFAULT_DELAY_SECS, RemoteDevice) +from homeassistant.const import ( + CONF_NAME, CONF_HOST, CONF_TOKEN, CONF_TIMEOUT, + ATTR_ENTITY_ID, ATTR_HIDDEN, CONF_COMMAND) +import homeassistant.helpers.config_validation as cv +from homeassistant.util.dt import utcnow + +REQUIREMENTS = ['python-miio==0.3.5'] + +_LOGGER = logging.getLogger(__name__) + +SERVICE_LEARN = 'xiaomi_miio_learn_command' +PLATFORM = 'xiaomi_miio' + +CONF_SLOT = 'slot' +CONF_COMMANDS = 'commands' + +DEFAULT_TIMEOUT = 10 +DEFAULT_SLOT = 1 + +LEARN_COMMAND_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): vol.All(str), + vol.Optional(CONF_TIMEOUT, default=10): + vol.All(int, vol.Range(min=0)), + vol.Optional(CONF_SLOT, default=1): + vol.All(int, vol.Range(min=1, max=1000000)), +}) + +COMMAND_SCHEMA = vol.Schema({ + vol.Required(CONF_COMMAND): vol.All(cv.ensure_list, [cv.string]) + }) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): + vol.All(int, vol.Range(min=0)), + vol.Optional(CONF_SLOT, default=DEFAULT_SLOT): + vol.All(int, vol.Range(min=1, max=1000000)), + vol.Optional(ATTR_HIDDEN, default=True): cv.boolean, + vol.Required(CONF_TOKEN): vol.All(str, vol.Length(min=32, max=32)), + vol.Optional(CONF_COMMANDS, default={}): + vol.Schema({cv.slug: COMMAND_SCHEMA}), +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Xiaomi IR Remote (Chuangmi IR) platform.""" + from miio import ChuangmiIr, DeviceException + + host = config.get(CONF_HOST) + token = config.get(CONF_TOKEN) + + # Create handler + _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) + device = ChuangmiIr(host, token) + + # Check that we can communicate with device. + try: + device.info() + except DeviceException as ex: + _LOGGER.error("Token not accepted by device : %s", ex) + return + + if PLATFORM not in hass.data: + hass.data[PLATFORM] = {} + + friendly_name = config.get(CONF_NAME, "xiaomi_miio_" + + host.replace('.', '_')) + slot = config.get(CONF_SLOT) + timeout = config.get(CONF_TIMEOUT) + + hidden = config.get(ATTR_HIDDEN) + + xiaomi_miio_remote = XiaomiMiioRemote( + friendly_name, device, slot, timeout, + hidden, config.get(CONF_COMMANDS)) + + hass.data[PLATFORM][host] = xiaomi_miio_remote + + async_add_devices([xiaomi_miio_remote]) + + @asyncio.coroutine + def async_service_handler(service): + """Handle a learn command.""" + if service.service != SERVICE_LEARN: + _LOGGER.error("We should not handle service: %s", service.service) + return + + entity_id = service.data.get(ATTR_ENTITY_ID) + entity = None + for remote in hass.data[PLATFORM].values(): + if remote.entity_id == entity_id: + entity = remote + + if not entity: + _LOGGER.error("entity_id: '%s' not found", entity_id) + return + + device = entity.device + + slot = service.data.get(CONF_SLOT, entity.slot) + + yield from hass.async_add_job(device.learn, slot) + + timeout = service.data.get(CONF_TIMEOUT, entity.timeout) + + _LOGGER.info("Press the key you want Home Assistant to learn") + start_time = utcnow() + while (utcnow() - start_time) < timedelta(seconds=timeout): + message = yield from hass.async_add_job( + device.read, slot) + _LOGGER.debug("Message recieved from device: '%s'", message) + + if 'code' in message and message['code']: + log_msg = "Received command is: {}".format(message['code']) + _LOGGER.info(log_msg) + hass.components.persistent_notification.async_create( + log_msg, title='Xiaomi Miio Remote') + return + + if ('error' in message and + message['error']['message'] == "learn timeout"): + yield from hass.async_add_job(device.learn, slot) + + yield from asyncio.sleep(1, loop=hass.loop) + + _LOGGER.error("Timeout. No infrared command captured") + hass.components.persistent_notification.async_create( + "Timeout. No infrared command captured", + title='Xiaomi Miio Remote') + + hass.services.async_register(DOMAIN, SERVICE_LEARN, async_service_handler, + schema=LEARN_COMMAND_SCHEMA) + + +class XiaomiMiioRemote(RemoteDevice): + """Representation of a Xiaomi Miio Remote device.""" + + def __init__(self, friendly_name, device, + slot, timeout, hidden, commands): + """Initialize the remote.""" + self._name = friendly_name + self._device = device + self._is_hidden = hidden + self._slot = slot + self._timeout = timeout + self._state = False + self._commands = commands + + @property + def name(self): + """Return the name of the remote.""" + return self._name + + @property + def device(self): + """Return the remote object.""" + return self._device + + @property + def hidden(self): + """Return if we should hide entity.""" + return self._is_hidden + + @property + def slot(self): + """Return the slot to save learned command.""" + return self._slot + + @property + def timeout(self): + """Return the timeout for learning command.""" + return self._timeout + + @property + def is_on(self): + """Return False if device is unreachable, else True.""" + from miio import DeviceException + try: + self.device.info() + return True + except DeviceException: + return False + + @property + def should_poll(self): + """We should not be polled for device up state.""" + return False + + @property + def device_state_attributes(self): + """Hide remote by default.""" + if self._is_hidden: + return {'hidden': 'true'} + else: + return + + # pylint: disable=R0201 + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the device on.""" + _LOGGER.error("Device does not support turn_on, " + + "please use 'remote.send_command' to send commands.") + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the device off.""" + _LOGGER.error("Device does not support turn_off, " + + "please use 'remote.send_command' to send commands.") + + # pylint: enable=R0201 + def _send_command(self, payload): + """Send a command.""" + from miio import DeviceException + + _LOGGER.debug("Sending payload: '%s'", payload) + try: + self.device.play(payload) + except DeviceException as ex: + _LOGGER.error( + "Transmit of IR command failed, %s, exception: %s", + payload, ex) + + def send_command(self, command, **kwargs): + """Wrapper for _send_command.""" + num_repeats = kwargs.get(ATTR_NUM_REPEATS) + + delay = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) + + for _ in range(num_repeats): + for payload in command: + if payload in self._commands: + for local_payload in self._commands[payload][CONF_COMMAND]: + self._send_command(local_payload) + else: + self._send_command(payload) + time.sleep(delay) diff --git a/requirements_all.txt b/requirements_all.txt index ad86e129ae0..804409c946f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -918,6 +918,7 @@ python-juicenet==0.0.5 # homeassistant.components.fan.xiaomi_miio # homeassistant.components.light.xiaomi_miio +# homeassistant.components.remote.xiaomi_miio # homeassistant.components.switch.xiaomi_miio # homeassistant.components.vacuum.xiaomi_miio python-miio==0.3.5