Broadlink remote (#26528)
* Add broadlink remote control platform * Fix order of the imports * Add remote.py to .coveragerc * Optimize MAC address validation * Use storage helper class and improve code readability * Add me to the manifest as a code owner * Fix dosctring * Add me to the code owners * Remove storage schemas, rename storage keys and improve readability
This commit is contained in:
parent
67498595e4
commit
5a24dbf599
5 changed files with 380 additions and 2 deletions
|
@ -94,6 +94,7 @@ omit =
|
|||
homeassistant/components/bom/sensor.py
|
||||
homeassistant/components/bom/weather.py
|
||||
homeassistant/components/braviatv/media_player.py
|
||||
homeassistant/components/broadlink/remote.py
|
||||
homeassistant/components/broadlink/sensor.py
|
||||
homeassistant/components/broadlink/switch.py
|
||||
homeassistant/components/brottsplatskartan/sensor.py
|
||||
|
|
|
@ -50,7 +50,7 @@ homeassistant/components/bizkaibus/* @UgaitzEtxebarria
|
|||
homeassistant/components/blink/* @fronzbot
|
||||
homeassistant/components/bmw_connected_drive/* @gerard33
|
||||
homeassistant/components/braviatv/* @robbiet480
|
||||
homeassistant/components/broadlink/* @danielhiversen
|
||||
homeassistant/components/broadlink/* @danielhiversen @felipediel
|
||||
homeassistant/components/brunt/* @eavanvalkenburg
|
||||
homeassistant/components/bt_smarthub/* @jxwolstenholme
|
||||
homeassistant/components/buienradar/* @mjj4791 @ties
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
"""The broadlink component."""
|
||||
import asyncio
|
||||
from base64 import b64decode, b64encode
|
||||
from binascii import unhexlify
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
|
||||
from datetime import timedelta
|
||||
|
@ -27,6 +29,31 @@ def data_packet(value):
|
|||
return b64decode(value)
|
||||
|
||||
|
||||
def hostname(value):
|
||||
"""Validate a hostname."""
|
||||
host = str(value).lower()
|
||||
if len(host) > 253:
|
||||
raise ValueError
|
||||
if host[-1] == ".":
|
||||
host = host[:-1]
|
||||
allowed = re.compile(r"(?!-)[a-z\d-]{1,63}(?<!-)$")
|
||||
if not all(allowed.match(elem) for elem in host.split(".")):
|
||||
raise ValueError
|
||||
return host
|
||||
|
||||
|
||||
def mac_address(value):
|
||||
"""Validate and coerce a 48-bit MAC address."""
|
||||
mac = str(value).lower()
|
||||
if len(mac) == 17:
|
||||
mac = mac[0:2] + mac[3:5] + mac[6:8] + mac[9:11] + mac[12:14] + mac[15:17]
|
||||
elif len(mac) == 14:
|
||||
mac = mac[0:2] + mac[2:4] + mac[5:7] + mac[7:9] + mac[10:12] + mac[12:14]
|
||||
elif len(mac) != 12:
|
||||
raise ValueError
|
||||
return unhexlify(mac)
|
||||
|
||||
|
||||
SERVICE_SEND_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
],
|
||||
"dependencies": [],
|
||||
"codeowners": [
|
||||
"@danielhiversen"
|
||||
"@danielhiversen",
|
||||
"@felipediel"
|
||||
]
|
||||
}
|
||||
|
|
349
homeassistant/components/broadlink/remote.py
Normal file
349
homeassistant/components/broadlink/remote.py
Normal file
|
@ -0,0 +1,349 @@
|
|||
"""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
|
Loading…
Add table
Reference in a new issue