core/homeassistant/components/broadlink/remote.py

317 lines
10 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 broadlink as blk
from broadlink.exceptions import (
AuthorizationError,
BroadlinkException,
DeviceOfflineError,
ReadError,
StorageError,
)
import voluptuous as vol
from homeassistant.components.remote import (
ATTR_ALTERNATIVE,
ATTR_COMMAND,
ATTR_DELAY_SECS,
ATTR_DEVICE,
ATTR_NUM_REPEATS,
DEFAULT_DELAY_SECS,
DOMAIN as COMPONENT,
PLATFORM_SCHEMA,
SUPPORT_LEARN_COMMAND,
RemoteEntity,
)
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_TIMEOUT, CONF_TYPE
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
from .const import (
DEFAULT_NAME,
DEFAULT_PORT,
DEFAULT_TIMEOUT,
LEARNING_TIMEOUT,
RM4_TYPES,
RM_TYPES,
)
from .device import BroadlinkDevice
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(minutes=2)
CODE_STORAGE_VERSION = 1
FLAG_STORAGE_VERSION = 1
FLAG_SAVE_DELAY = 15
DEVICE_TYPES = RM_TYPES + RM4_TYPES
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}
)
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_TYPE, default=DEVICE_TYPES[0]): vol.In(DEVICE_TYPES),
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]
model = config[CONF_TYPE]
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)
if model in RM_TYPES:
api = blk.rm((host, DEFAULT_PORT), mac_addr, None)
else:
api = blk.rm4((host, DEFAULT_PORT), mac_addr, None)
api.timeout = timeout
device = BroadlinkDevice(hass, api)
code_storage = Store(hass, CODE_STORAGE_VERSION, f"broadlink_{unique_id}_codes")
flag_storage = Store(hass, FLAG_STORAGE_VERSION, f"broadlink_{unique_id}_flags")
remote = BroadlinkRemote(name, unique_id, device, code_storage, flag_storage)
connected, loaded = await asyncio.gather(
device.async_connect(), remote.async_load_storage_files()
)
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(RemoteEntity):
"""Representation of a Broadlink remote."""
def __init__(self, name, unique_id, device, code_storage, flag_storage):
"""Initialize the remote."""
self.device = device
self._name = name
self._unique_id = unique_id
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.device.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 device."""
if not self.available:
await self.device.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 (AuthorizationError, DeviceOfflineError):
break
except BroadlinkException:
pass
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.device.async_request(
self.device.api.send_data, data_packet(code)
)
except ValueError:
_LOGGER.error("Failed to send '%s/%s': Invalid code", command, device)
return False
except BroadlinkException as err_msg:
_LOGGER.error("Failed to send '%s/%s': %s", command, device, err_msg)
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]
if not self._state:
return
should_store = False
for command in commands:
try:
code = await self._async_learn_command(command)
if toggle:
code = [code, await self._async_learn_command(command)]
except (AuthorizationError, DeviceOfflineError) as err_msg:
_LOGGER.error("Failed to learn '%s': %s", command, err_msg)
break
except (BroadlinkException, TimeoutError) as err_msg:
_LOGGER.error("Failed to learn '%s': %s", command, err_msg)
continue
else:
self._codes.setdefault(device, {}).update({command: code})
should_store = True
if should_store:
await self._code_storage.async_save(self._codes)
async def _async_learn_command(self, command):
"""Learn a command from a remote."""
try:
await self.device.async_request(self.device.api.enter_learning)
except BroadlinkException as err_msg:
_LOGGER.debug("Failed to enter learning mode: %s", err_msg)
raise
self.hass.components.persistent_notification.async_create(
f"Press the '{command}' button.",
title="Learn command",
notification_id="learn_command",
)
try:
start_time = utcnow()
while (utcnow() - start_time) < LEARNING_TIMEOUT:
await asyncio.sleep(1)
try:
code = await self.device.async_request(self.device.api.check_data)
except (ReadError, StorageError):
continue
return b64encode(code).decode("utf8")
raise TimeoutError("No code received")
finally:
self.hass.components.persistent_notification.async_dismiss(
notification_id="learn_command"
)