core/homeassistant/components/broadlink/remote.py

350 lines
11 KiB
Python

"""Support for Broadlink IR/RF remotes."""
import asyncio
from base64 import b64encode
from binascii import hexlify
from collections import defaultdict
from datetime import timedelta
from ipaddress import ip_address
from itertools import product
import logging
import socket
import broadlink
import voluptuous as vol
from homeassistant.components.remote import (
ATTR_ALTERNATIVE,
ATTR_COMMAND,
ATTR_DELAY_SECS,
ATTR_DEVICE,
ATTR_NUM_REPEATS,
ATTR_TIMEOUT,
DEFAULT_DELAY_SECS,
DOMAIN as COMPONENT,
PLATFORM_SCHEMA,
SUPPORT_LEARN_COMMAND,
RemoteDevice,
)
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_TIMEOUT
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.storage import Store
from homeassistant.util.dt import utcnow
from . import DOMAIN, data_packet, hostname, mac_address
_LOGGER = logging.getLogger(__name__)
DEFAULT_LEARNING_TIMEOUT = 20
DEFAULT_NAME = "Broadlink"
DEFAULT_PORT = 80
DEFAULT_RETRY = 3
DEFAULT_TIMEOUT = 5
SCAN_INTERVAL = timedelta(minutes=2)
CODE_STORAGE_KEY = "broadlink_{}_codes"
CODE_STORAGE_VERSION = 1
FLAG_STORAGE_KEY = "broadlink_{}_flags"
FLAG_STORAGE_VERSION = 1
FLAG_SAVE_DELAY = 15
MINIMUM_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_COMMAND): vol.All(
cv.ensure_list, [vol.All(cv.string, vol.Length(min=1))], vol.Length(min=1)
),
vol.Required(ATTR_DEVICE): vol.All(cv.string, vol.Length(min=1)),
},
extra=vol.ALLOW_EXTRA,
)
SERVICE_SEND_SCHEMA = MINIMUM_SERVICE_SCHEMA.extend(
{vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): vol.Coerce(float)}
)
SERVICE_LEARN_SCHEMA = MINIMUM_SERVICE_SCHEMA.extend(
{
vol.Optional(ATTR_ALTERNATIVE, default=False): cv.boolean,
vol.Optional(ATTR_TIMEOUT, default=DEFAULT_LEARNING_TIMEOUT): cv.positive_int,
}
)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): vol.All(vol.Any(hostname, ip_address), cv.string),
vol.Required(CONF_MAC): mac_address,
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
}
)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Broadlink remote."""
host = config[CONF_HOST]
mac_addr = config[CONF_MAC]
timeout = config[CONF_TIMEOUT]
name = config[CONF_NAME]
unique_id = f"remote_{hexlify(mac_addr).decode('utf-8')}"
if unique_id in hass.data.setdefault(DOMAIN, {}).setdefault(COMPONENT, []):
_LOGGER.error("Duplicate: %s", unique_id)
return
hass.data[DOMAIN][COMPONENT].append(unique_id)
api = broadlink.rm((host, DEFAULT_PORT), mac_addr, None)
api.timeout = timeout
code_storage = Store(hass, CODE_STORAGE_VERSION, CODE_STORAGE_KEY.format(unique_id))
flag_storage = Store(hass, FLAG_STORAGE_VERSION, FLAG_STORAGE_KEY.format(unique_id))
remote = BroadlinkRemote(name, unique_id, api, code_storage, flag_storage)
connected, loaded = (False, False)
try:
connected, loaded = await asyncio.gather(
hass.async_add_executor_job(api.auth), remote.async_load_storage_files()
)
except socket.error:
pass
if not connected:
hass.data[DOMAIN][COMPONENT].remove(unique_id)
raise PlatformNotReady
if not loaded:
_LOGGER.error("Failed to set up %s", unique_id)
hass.data[DOMAIN][COMPONENT].remove(unique_id)
return
async_add_entities([remote], False)
class BroadlinkRemote(RemoteDevice):
"""Representation of a Broadlink remote."""
def __init__(self, name, unique_id, api, code_storage, flag_storage):
"""Initialize the remote."""
self._name = name
self._unique_id = unique_id
self._api = api
self._code_storage = code_storage
self._flag_storage = flag_storage
self._codes = {}
self._flags = defaultdict(int)
self._state = True
self._available = True
@property
def name(self):
"""Return the name of the remote."""
return self._name
@property
def unique_id(self):
"""Return the unique ID of the remote."""
return self._unique_id
@property
def is_on(self):
"""Return True if the remote is on."""
return self._state
@property
def available(self):
"""Return True if the remote is available."""
return self._available
@property
def supported_features(self):
"""Flag supported features."""
return SUPPORT_LEARN_COMMAND
@callback
def get_flags(self):
"""Return dictionary of toggle flags.
A toggle flag indicates whether `self._async_send_code()`
should send an alternative code for a key device.
"""
return self._flags
async def async_turn_on(self, **kwargs):
"""Turn the remote on."""
self._state = True
async def async_turn_off(self, **kwargs):
"""Turn the remote off."""
self._state = False
async def async_update(self):
"""Update the availability of the remote."""
if not self.available:
await self._async_connect()
async def async_load_storage_files(self):
"""Load codes and toggle flags from storage files."""
try:
self._codes.update(await self._code_storage.async_load() or {})
self._flags.update(await self._flag_storage.async_load() or {})
except HomeAssistantError:
return False
return True
async def async_send_command(self, command, **kwargs):
"""Send a list of commands to a device."""
kwargs[ATTR_COMMAND] = command
kwargs = SERVICE_SEND_SCHEMA(kwargs)
commands = kwargs[ATTR_COMMAND]
device = kwargs[ATTR_DEVICE]
repeat = kwargs[ATTR_NUM_REPEATS]
delay = kwargs[ATTR_DELAY_SECS]
if not self._state:
return
should_delay = False
for _, cmd in product(range(repeat), commands):
try:
should_delay = await self._async_send_code(
cmd, device, delay if should_delay else 0
)
except ConnectionError:
break
self._flag_storage.async_delay_save(self.get_flags, FLAG_SAVE_DELAY)
async def _async_send_code(self, command, device, delay):
"""Send a code to a device.
For toggle commands, alternate between codes in a list,
ensuring that the same code is never sent twice in a row.
"""
try:
code = self._codes[device][command]
except KeyError:
_LOGGER.error("Failed to send '%s/%s': command not found", command, device)
return False
if isinstance(code, list):
code = code[self._flags[device]]
should_alternate = True
else:
should_alternate = False
await asyncio.sleep(delay)
try:
await self._async_attempt(self._api.send_data, data_packet(code))
except ValueError:
_LOGGER.error("Failed to send '%s/%s': invalid code", command, device)
return False
except ConnectionError:
_LOGGER.error("Failed to send '%s/%s': remote is offline", command, device)
raise
if should_alternate:
self._flags[device] ^= 1
return True
async def async_learn_command(self, **kwargs):
"""Learn a list of commands from a remote."""
kwargs = SERVICE_LEARN_SCHEMA(kwargs)
commands = kwargs[ATTR_COMMAND]
device = kwargs[ATTR_DEVICE]
toggle = kwargs[ATTR_ALTERNATIVE]
timeout = kwargs[ATTR_TIMEOUT]
if not self._state:
return
should_store = False
for command in commands:
try:
should_store |= await self._async_learn_code(
command, device, toggle, timeout
)
except ConnectionError:
break
if should_store:
await self._code_storage.async_save(self._codes)
async def _async_learn_code(self, command, device, toggle, timeout):
"""Learn a code from a remote.
Capture an aditional code for toggle commands.
"""
try:
if not toggle:
code = await self._async_capture_code(command, timeout)
else:
code = [
await self._async_capture_code(command, timeout),
await self._async_capture_code(command, timeout),
]
except (ValueError, TimeoutError):
_LOGGER.error(
"Failed to learn '%s/%s': no signal received", command, device
)
return False
except ConnectionError:
_LOGGER.error("Failed to learn '%s/%s': remote is offline", command, device)
raise
self._codes.setdefault(device, {}).update({command: code})
return True
async def _async_capture_code(self, command, timeout):
"""Enter learning mode and capture a code from a remote."""
await self._async_attempt(self._api.enter_learning)
self.hass.components.persistent_notification.async_create(
f"Press the '{command}' button.",
title="Learn command",
notification_id="learn_command",
)
code = None
start_time = utcnow()
while (utcnow() - start_time) < timedelta(seconds=timeout):
code = await self.hass.async_add_executor_job(self._api.check_data)
if code:
break
await asyncio.sleep(1)
self.hass.components.persistent_notification.async_dismiss(
notification_id="learn_command"
)
if not code:
raise TimeoutError
if all(not value for value in code):
raise ValueError
return b64encode(code).decode("utf8")
async def _async_attempt(self, function, *args):
"""Retry a socket-related function until it succeeds."""
for retry in range(DEFAULT_RETRY):
if retry and not await self._async_connect():
continue
try:
await self.hass.async_add_executor_job(function, *args)
except socket.error:
continue
return
raise ConnectionError
async def _async_connect(self):
"""Connect to the remote."""
try:
auth = await self.hass.async_add_executor_job(self._api.auth)
except socket.error:
auth = False
if auth and not self._available:
_LOGGER.warning("Connected to the remote")
self._available = True
elif not auth and self._available:
_LOGGER.warning("Disconnected from the remote")
self._available = False
return auth