Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

python-miio remote. #11891

Merged
merged 20 commits into from
Feb 6, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions homeassistant/components/remote/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
255 changes: 255 additions & 0 deletions homeassistant/components/remote/xiaomi_miio.py
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two constants aren't used anymore, too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are now, forgot to use them in the correct place.

DEFAULT_SLOT = 1

LEARN_COMMAND_SCHEMA = vol.Schema({
vol.Required(ATTR_ENTITY_ID): vol.All(str),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be cv.entity_id or cv.entity_ids.

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] = {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you should use a key specific for the remote xiaomi_miio platform? Other xiaomi_miio platforms might want to store things in hass.data in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am pretty sure that this part is correct, if you look at the other python-miio platforms they do the same thing: Create a dict if one is not on the PLATFORM key, and save the devices in the new dict using their host (ip) as key. The host should be unique but there may be some edge cases where it is not.

See these lines in the vacuum python-miio:
https://github.com/home-assistant/home-assistant/blob/5ba02c531e4d7e68dc8b811ba2ebc7438afe6f16/homeassistant/components/vacuum/xiaomi_miio.py#L91-L103


friendly_name = config.get(CONF_NAME, "xiaomi_miio_" +
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use new style string formatting, not string concatenation.

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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would this happen?

_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():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could use a generator expression and next which is fast and clean.

entity = next((
    remote for remote in hass.data[PLATFORM].values()
    if remote.entity_id == entity_id), None)

if remote.entity_id == entity_id:
entity = remote

if not entity:
_LOGGER.error("entity_id: '%s' not found", entity_id)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove quotes around 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

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a should_poll method. Just to make clear it's a polling entity or not.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@property
def name(self):
"""Return the name of the remote."""
return self._name

@property
def device(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the property isn't needed just make a regular instance attribute of it instead.

"""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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

"""Return the slot to save learned command."""
return self._slot

@property
def timeout(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

"""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'}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already reported in the base entity class.

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, " +
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove +, it's not needed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usually we let not implemented methods raise that error if called. I'd just remove these methods from the class here.

"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, " +
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

"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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the xiaomi_miio library supports asyncio it was better when this method was async, since we want to sleep here. We usually frown upon sync sleep in worker threads which this now does. On the other hand async sleep doesn't cost anything.

I'm not sure why @pvizeli wanted this to be done sync?

1 change: 1 addition & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down