Implement config flow in the Broadlink integration (#36914)
* Implement config flow in the Broadlink integration * General improvements to the Broadlink config flow * Remove unnecessary else after return * Fix translations * Rename device to device_entry * Add tests for the config flow * Improve docstrings * Test we do not accept more than one config entry per device * Improve helpers * Allow empty packets * Allow multiple config files for switches related to the same device * Rename mock_device to mock_api * General improvements * Make new attempts before marking the device as unavailable * Let the name be the template for the entity_id * Handle OSError * Test network unavailable in the configuration flow * Rename lock attribute * Update manifest.json * Import devices from platforms * Test import flow * Add deprecation warnings * General improvements * Rename deprecate to discontinue * Test device setup * Add type attribute to mock api * Test we handle an update failure at startup * Remove BroadlinkDevice from tests * Remove device.py from .coveragerc * Add tests for the config flow * Add tests for the device * Test device registry and update listener * Test MAC address validation * Add tests for the device * Extract domains and types to a helper function * Do not patch integration details * Add tests for the device * Set device classes where appropriate * Set an appropriate connection class * Do not set device class for custom switches * Fix tests and improve code readability * Use RM4 to test authentication errors * Handle BroadlinkException in the authentication
This commit is contained in:
parent
eb4f667a1a
commit
a2c1f08c8c
21 changed files with 2497 additions and 795 deletions
|
@ -100,11 +100,12 @@ omit =
|
|||
homeassistant/components/braviatv/__init__.py
|
||||
homeassistant/components/braviatv/const.py
|
||||
homeassistant/components/braviatv/media_player.py
|
||||
homeassistant/components/broadlink/__init__.py
|
||||
homeassistant/components/broadlink/const.py
|
||||
homeassistant/components/broadlink/device.py
|
||||
homeassistant/components/broadlink/remote.py
|
||||
homeassistant/components/broadlink/sensor.py
|
||||
homeassistant/components/broadlink/switch.py
|
||||
homeassistant/components/broadlink/updater.py
|
||||
homeassistant/components/brottsplatskartan/sensor.py
|
||||
homeassistant/components/browser/*
|
||||
homeassistant/components/brunt/cover.py
|
||||
|
|
|
@ -1,125 +1,34 @@
|
|||
"""The broadlink component."""
|
||||
import asyncio
|
||||
from base64 import b64decode, b64encode
|
||||
from binascii import unhexlify
|
||||
"""The Broadlink integration."""
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
import re
|
||||
|
||||
from broadlink.exceptions import BroadlinkException, ReadError, StorageError
|
||||
import voluptuous as vol
|
||||
from .const import DOMAIN
|
||||
from .device import BroadlinkDevice
|
||||
|
||||
from homeassistant.const import CONF_HOST
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import CONF_PACKET, DOMAIN, LEARNING_TIMEOUT, SERVICE_LEARN, SERVICE_SEND
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_RETRY = 3
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def data_packet(value):
|
||||
"""Decode a data packet given for broadlink."""
|
||||
value = cv.string(value)
|
||||
extra = len(value) % 4
|
||||
if extra > 0:
|
||||
value = value + ("=" * (4 - extra))
|
||||
return b64decode(value)
|
||||
@dataclass
|
||||
class BroadlinkData:
|
||||
"""Class for sharing data within the Broadlink integration."""
|
||||
|
||||
devices: dict = field(default_factory=dict)
|
||||
platforms: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def hostname(value):
|
||||
"""Validate a hostname."""
|
||||
host = str(value)
|
||||
if len(host) > 253:
|
||||
raise ValueError
|
||||
if host[-1] == ".":
|
||||
host = host[:-1]
|
||||
allowed = re.compile(r"(?![_-])[a-z\d_-]{1,63}(?<![_-])$", flags=re.IGNORECASE)
|
||||
if not all(allowed.match(elem) for elem in host.split(".")):
|
||||
raise ValueError
|
||||
return host
|
||||
async def async_setup(hass, config):
|
||||
"""Set up the Broadlink integration."""
|
||||
hass.data[DOMAIN] = BroadlinkData()
|
||||
return True
|
||||
|
||||
|
||||
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)
|
||||
async def async_setup_entry(hass, entry):
|
||||
"""Set up a Broadlink device from a config entry."""
|
||||
device = BroadlinkDevice(hass, entry)
|
||||
return await device.async_setup()
|
||||
|
||||
|
||||
SERVICE_SEND_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_PACKET): vol.All(cv.ensure_list, [data_packet]),
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_LEARN_SCHEMA = vol.Schema({vol.Required(CONF_HOST): cv.string})
|
||||
|
||||
|
||||
async def async_setup_service(hass, host, device):
|
||||
"""Register a device for given host for use in services."""
|
||||
hass.data.setdefault(DOMAIN, {})[host] = device
|
||||
|
||||
if hass.services.has_service(DOMAIN, SERVICE_LEARN):
|
||||
return
|
||||
|
||||
async def async_learn_command(call):
|
||||
"""Learn a packet from remote."""
|
||||
|
||||
device = hass.data[DOMAIN][call.data[CONF_HOST]]
|
||||
|
||||
try:
|
||||
await device.async_request(device.api.enter_learning)
|
||||
except BroadlinkException as err_msg:
|
||||
_LOGGER.error("Failed to enter learning mode: %s", err_msg)
|
||||
return
|
||||
|
||||
_LOGGER.info("Press the key you want Home Assistant to learn")
|
||||
start_time = utcnow()
|
||||
while (utcnow() - start_time) < LEARNING_TIMEOUT:
|
||||
await asyncio.sleep(1)
|
||||
try:
|
||||
packet = await device.async_request(device.api.check_data)
|
||||
except (ReadError, StorageError):
|
||||
continue
|
||||
except BroadlinkException as err_msg:
|
||||
_LOGGER.error("Failed to learn: %s", err_msg)
|
||||
return
|
||||
else:
|
||||
data = b64encode(packet).decode("utf8")
|
||||
log_msg = f"Received packet is: {data}"
|
||||
_LOGGER.info(log_msg)
|
||||
hass.components.persistent_notification.async_create(
|
||||
log_msg, title="Broadlink switch"
|
||||
)
|
||||
return
|
||||
_LOGGER.error("Failed to learn: No signal received")
|
||||
hass.components.persistent_notification.async_create(
|
||||
"No signal was received", title="Broadlink switch"
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_LEARN, async_learn_command, schema=SERVICE_LEARN_SCHEMA
|
||||
)
|
||||
|
||||
async def async_send_packet(call):
|
||||
"""Send a packet."""
|
||||
device = hass.data[DOMAIN][call.data[CONF_HOST]]
|
||||
packets = call.data[CONF_PACKET]
|
||||
for packet in packets:
|
||||
try:
|
||||
await device.async_request(device.api.send_data, packet)
|
||||
except BroadlinkException as err_msg:
|
||||
_LOGGER.error("Failed to send packet: %s", err_msg)
|
||||
return
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SEND, async_send_packet, schema=SERVICE_SEND_SCHEMA
|
||||
)
|
||||
async def async_unload_entry(hass, entry):
|
||||
"""Unload a config entry."""
|
||||
device = hass.data[DOMAIN].devices.pop(entry.entry_id)
|
||||
return await device.async_unload()
|
||||
|
|
270
homeassistant/components/broadlink/config_flow.py
Normal file
270
homeassistant/components/broadlink/config_flow.py
Normal file
|
@ -0,0 +1,270 @@
|
|||
"""Config flow for Broadlink devices."""
|
||||
import errno
|
||||
from functools import partial
|
||||
import socket
|
||||
|
||||
import broadlink as blk
|
||||
from broadlink.exceptions import (
|
||||
AuthenticationError,
|
||||
BroadlinkException,
|
||||
DeviceOfflineError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_TIMEOUT, CONF_TYPE
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from . import LOGGER
|
||||
from .const import ( # pylint: disable=unused-import
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_TIMEOUT,
|
||||
DOMAIN,
|
||||
)
|
||||
from .helpers import format_mac
|
||||
|
||||
|
||||
class BroadlinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a Broadlink config flow."""
|
||||
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Broadlink flow."""
|
||||
self.device = None
|
||||
|
||||
async def async_set_device(self, device, raise_on_progress=True):
|
||||
"""Define a device for the config flow."""
|
||||
await self.async_set_unique_id(
|
||||
device.mac.hex(), raise_on_progress=raise_on_progress
|
||||
)
|
||||
self.device = device
|
||||
|
||||
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
|
||||
self.context["title_placeholders"] = {
|
||||
"name": device.name,
|
||||
"model": device.model,
|
||||
"host": device.host[0],
|
||||
}
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle a flow initiated by the user."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
host = user_input[CONF_HOST]
|
||||
timeout = user_input.get(CONF_TIMEOUT, DEFAULT_TIMEOUT)
|
||||
|
||||
try:
|
||||
hello = partial(blk.discover, discover_ip_address=host, timeout=timeout)
|
||||
device = (await self.hass.async_add_executor_job(hello))[0]
|
||||
|
||||
except IndexError:
|
||||
errors["base"] = "cannot_connect"
|
||||
err_msg = "Device not found"
|
||||
|
||||
except OSError as err:
|
||||
if err.errno in {errno.EINVAL, socket.EAI_NONAME}:
|
||||
errors["base"] = "invalid_host"
|
||||
err_msg = "Invalid hostname or IP address"
|
||||
elif err.errno == errno.ENETUNREACH:
|
||||
errors["base"] = "cannot_connect"
|
||||
err_msg = str(err)
|
||||
else:
|
||||
errors["base"] = "unknown"
|
||||
err_msg = str(err)
|
||||
|
||||
else:
|
||||
device.timeout = timeout
|
||||
|
||||
if self.unique_id is None:
|
||||
await self.async_set_device(device)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_HOST: device.host[0], CONF_TIMEOUT: timeout}
|
||||
)
|
||||
return await self.async_step_auth()
|
||||
|
||||
# The user came from a factory reset.
|
||||
# We need to check whether the host is correct.
|
||||
if device.mac == self.device.mac:
|
||||
await self.async_set_device(device, raise_on_progress=False)
|
||||
return await self.async_step_auth()
|
||||
|
||||
errors["base"] = "invalid_host"
|
||||
err_msg = (
|
||||
"Invalid host for this configuration flow. The MAC address should be "
|
||||
f"{format_mac(self.device.mac)}, but {format_mac(device.mac)} was given"
|
||||
)
|
||||
|
||||
LOGGER.error("Failed to connect to the device at %s: %s", host, err_msg)
|
||||
|
||||
if self.source == config_entries.SOURCE_IMPORT:
|
||||
return self.async_abort(reason=errors["base"])
|
||||
|
||||
data_schema = {
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
|
||||
}
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=vol.Schema(data_schema), errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_auth(self):
|
||||
"""Authenticate to the device."""
|
||||
device = self.device
|
||||
errors = {}
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(device.auth)
|
||||
|
||||
except AuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
await self.async_set_unique_id(device.mac.hex())
|
||||
return await self.async_step_reset(errors=errors)
|
||||
|
||||
except DeviceOfflineError as err:
|
||||
errors["base"] = "cannot_connect"
|
||||
err_msg = str(err)
|
||||
|
||||
except BroadlinkException as err:
|
||||
errors["base"] = "unknown"
|
||||
err_msg = str(err)
|
||||
|
||||
except OSError as err:
|
||||
if err.errno == errno.ENETUNREACH:
|
||||
errors["base"] = "cannot_connect"
|
||||
err_msg = str(err)
|
||||
else:
|
||||
errors["base"] = "unknown"
|
||||
err_msg = str(err)
|
||||
|
||||
else:
|
||||
await self.async_set_unique_id(device.mac.hex())
|
||||
if self.source == config_entries.SOURCE_IMPORT:
|
||||
LOGGER.warning(
|
||||
"The %s at %s is ready to be configured. Please "
|
||||
"click Configuration in the sidebar and click "
|
||||
"Integrations. Then find the device there and click "
|
||||
"Configure to finish the setup",
|
||||
device.model,
|
||||
device.host[0],
|
||||
)
|
||||
|
||||
if device.is_locked:
|
||||
return await self.async_step_unlock()
|
||||
return await self.async_step_finish()
|
||||
|
||||
await self.async_set_unique_id(device.mac.hex())
|
||||
LOGGER.error(
|
||||
"Failed to authenticate to the device at %s: %s", device.host[0], err_msg
|
||||
)
|
||||
return self.async_show_form(step_id="auth", errors=errors)
|
||||
|
||||
async def async_step_reset(self, user_input=None, errors=None):
|
||||
"""Guide the user to unlock the device manually.
|
||||
|
||||
We are unable to authenticate because the device is locked.
|
||||
The user needs to factory reset the device to make it work
|
||||
with Home Assistant.
|
||||
"""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="reset", errors=errors)
|
||||
|
||||
return await self.async_step_user(
|
||||
{CONF_HOST: self.device.host[0], CONF_TIMEOUT: self.device.timeout}
|
||||
)
|
||||
|
||||
async def async_step_unlock(self, user_input=None):
|
||||
"""Unlock the device.
|
||||
|
||||
The authentication succeeded, but the device is locked.
|
||||
We can offer an unlock to prevent authorization errors.
|
||||
"""
|
||||
device = self.device
|
||||
errors = {}
|
||||
|
||||
if user_input is None:
|
||||
pass
|
||||
|
||||
elif user_input["unlock"]:
|
||||
try:
|
||||
await self.hass.async_add_executor_job(device.set_lock, False)
|
||||
|
||||
except DeviceOfflineError as err:
|
||||
errors["base"] = "cannot_connect"
|
||||
err_msg = str(err)
|
||||
|
||||
except BroadlinkException as err:
|
||||
errors["base"] = "unknown"
|
||||
err_msg = str(err)
|
||||
|
||||
except OSError as err:
|
||||
if err.errno == errno.ENETUNREACH:
|
||||
errors["base"] = "cannot_connect"
|
||||
err_msg = str(err)
|
||||
else:
|
||||
errors["base"] = "unknown"
|
||||
err_msg = str(err)
|
||||
|
||||
else:
|
||||
return await self.async_step_finish()
|
||||
|
||||
LOGGER.error(
|
||||
"Failed to unlock the device at %s: %s", device.host[0], err_msg
|
||||
)
|
||||
|
||||
else:
|
||||
return await self.async_step_finish()
|
||||
|
||||
data_schema = {vol.Required("unlock", default=False): bool}
|
||||
return self.async_show_form(
|
||||
step_id="unlock", data_schema=vol.Schema(data_schema), errors=errors
|
||||
)
|
||||
|
||||
async def async_step_finish(self, user_input=None):
|
||||
"""Choose a name for the device and create config entry."""
|
||||
device = self.device
|
||||
errors = {}
|
||||
|
||||
# Abort reauthentication flow.
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_HOST: device.host[0], CONF_TIMEOUT: device.timeout}
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME],
|
||||
data={
|
||||
CONF_HOST: device.host[0],
|
||||
CONF_MAC: device.mac.hex(),
|
||||
CONF_TYPE: device.devtype,
|
||||
CONF_TIMEOUT: device.timeout,
|
||||
},
|
||||
)
|
||||
|
||||
data_schema = {vol.Required(CONF_NAME, default=device.name): str}
|
||||
return self.async_show_form(
|
||||
step_id="finish", data_schema=vol.Schema(data_schema), errors=errors
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_info):
|
||||
"""Import a device."""
|
||||
if any(
|
||||
import_info[CONF_HOST] == entry.data[CONF_HOST]
|
||||
for entry in self._async_current_entries()
|
||||
):
|
||||
return self.async_abort(reason="already_configured")
|
||||
return await self.async_step_user(import_info)
|
||||
|
||||
async def async_step_reauth(self, data):
|
||||
"""Reauthenticate to the device."""
|
||||
device = blk.gendevice(
|
||||
data[CONF_TYPE],
|
||||
(data[CONF_HOST], DEFAULT_PORT),
|
||||
bytes.fromhex(data[CONF_MAC]),
|
||||
name=data[CONF_NAME],
|
||||
)
|
||||
device.timeout = data[CONF_TIMEOUT]
|
||||
await self.async_set_device(device)
|
||||
return await self.async_step_reset()
|
|
@ -1,41 +1,15 @@
|
|||
"""Constants for broadlink platform."""
|
||||
from datetime import timedelta
|
||||
|
||||
CONF_PACKET = "packet"
|
||||
|
||||
DEFAULT_NAME = "Broadlink"
|
||||
DEFAULT_PORT = 80
|
||||
DEFAULT_RETRY = 3
|
||||
DEFAULT_TIMEOUT = 5
|
||||
"""Constants for the Broadlink integration."""
|
||||
from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
|
||||
DOMAIN = "broadlink"
|
||||
|
||||
LEARNING_TIMEOUT = timedelta(seconds=30)
|
||||
DOMAINS_AND_TYPES = (
|
||||
(REMOTE_DOMAIN, ("RM2", "RM4")),
|
||||
(SENSOR_DOMAIN, ("A1", "RM2", "RM4")),
|
||||
(SWITCH_DOMAIN, ("MP1", "RM2", "RM4", "SP1", "SP2")),
|
||||
)
|
||||
|
||||
SERVICE_LEARN = "learn"
|
||||
SERVICE_SEND = "send"
|
||||
|
||||
A1_TYPES = ["a1"]
|
||||
MP1_TYPES = ["mp1"]
|
||||
RM_TYPES = [
|
||||
"rm",
|
||||
"rm2",
|
||||
"rm_mini",
|
||||
"rm_mini_shate",
|
||||
"rm_pro_phicomm",
|
||||
"rm2_home_plus",
|
||||
"rm2_home_plus_gdt",
|
||||
"rm2_pro_plus",
|
||||
"rm2_pro_plus2",
|
||||
"rm2_pro_plus_bl",
|
||||
]
|
||||
RM4_TYPES = [
|
||||
"rm_mini3_newblackbean",
|
||||
"rm_mini3_redbean",
|
||||
"rm4_mini",
|
||||
"rm4_pro",
|
||||
"rm4c_mini",
|
||||
"rm4c_pro",
|
||||
]
|
||||
SP1_TYPES = ["sp1"]
|
||||
SP2_TYPES = ["sp2", "honeywell_sp2", "sp3", "spmini2", "spminiplus"]
|
||||
DEFAULT_PORT = 80
|
||||
DEFAULT_TIMEOUT = 5
|
||||
|
|
|
@ -1,57 +1,178 @@
|
|||
"""Support for Broadlink devices."""
|
||||
import asyncio
|
||||
from functools import partial
|
||||
import logging
|
||||
|
||||
import broadlink as blk
|
||||
from broadlink.exceptions import (
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
BroadlinkException,
|
||||
ConnectionClosedError,
|
||||
DeviceOfflineError,
|
||||
)
|
||||
|
||||
from .const import DEFAULT_RETRY
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_TIMEOUT, CONF_TYPE
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import DEFAULT_PORT, DOMAIN, DOMAINS_AND_TYPES
|
||||
from .updater import get_update_manager
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_domains(device_type):
|
||||
"""Return the domains available for a device type."""
|
||||
return {domain for domain, types in DOMAINS_AND_TYPES if device_type in types}
|
||||
|
||||
|
||||
class BroadlinkDevice:
|
||||
"""Manages a Broadlink device."""
|
||||
|
||||
def __init__(self, hass, api):
|
||||
def __init__(self, hass, config):
|
||||
"""Initialize the device."""
|
||||
self.hass = hass
|
||||
self.api = api
|
||||
self.available = None
|
||||
self.config = config
|
||||
self.api = None
|
||||
self.update_manager = None
|
||||
self.fw_version = None
|
||||
self.authorized = None
|
||||
self.reset_jobs = []
|
||||
|
||||
async def async_connect(self):
|
||||
"""Connect to the device."""
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self.config.title
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the unique id of the device."""
|
||||
return self.config.unique_id
|
||||
|
||||
@staticmethod
|
||||
async def async_update(hass, entry):
|
||||
"""Update the device and related entities.
|
||||
|
||||
Triggered when the device is renamed on the frontend.
|
||||
"""
|
||||
device_registry = await dr.async_get_registry(hass)
|
||||
device_entry = device_registry.async_get_device(
|
||||
{(DOMAIN, entry.unique_id)}, set()
|
||||
)
|
||||
device_registry.async_update_device(device_entry.id, name=entry.title)
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
async def async_setup(self):
|
||||
"""Set up the device and related entities."""
|
||||
config = self.config
|
||||
|
||||
api = blk.gendevice(
|
||||
config.data[CONF_TYPE],
|
||||
(config.data[CONF_HOST], DEFAULT_PORT),
|
||||
bytes.fromhex(config.data[CONF_MAC]),
|
||||
name=config.title,
|
||||
)
|
||||
api.timeout = config.data[CONF_TIMEOUT]
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(api.auth)
|
||||
|
||||
except AuthenticationError:
|
||||
await self._async_handle_auth_error()
|
||||
return False
|
||||
|
||||
except (DeviceOfflineError, OSError):
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
except BroadlinkException as err:
|
||||
_LOGGER.error(
|
||||
"Failed to authenticate to the device at %s: %s", api.host[0], err
|
||||
)
|
||||
return False
|
||||
|
||||
self.api = api
|
||||
self.authorized = True
|
||||
|
||||
update_manager = get_update_manager(self)
|
||||
coordinator = update_manager.coordinator
|
||||
await coordinator.async_refresh()
|
||||
if not coordinator.last_update_success:
|
||||
raise ConfigEntryNotReady()
|
||||
|
||||
self.update_manager = update_manager
|
||||
self.hass.data[DOMAIN].devices[config.entry_id] = self
|
||||
self.reset_jobs.append(config.add_update_listener(self.async_update))
|
||||
|
||||
try:
|
||||
self.fw_version = await self.hass.async_add_executor_job(api.get_fwversion)
|
||||
except (BroadlinkException, OSError):
|
||||
pass
|
||||
|
||||
# Forward entry setup to related domains.
|
||||
tasks = (
|
||||
self.hass.config_entries.async_forward_entry_setup(config, domain)
|
||||
for domain in get_domains(self.api.type)
|
||||
)
|
||||
for entry_setup in tasks:
|
||||
self.hass.async_create_task(entry_setup)
|
||||
|
||||
return True
|
||||
|
||||
async def async_unload(self):
|
||||
"""Unload the device and related entities."""
|
||||
if self.update_manager is None:
|
||||
return True
|
||||
|
||||
while self.reset_jobs:
|
||||
self.reset_jobs.pop()()
|
||||
|
||||
tasks = (
|
||||
self.hass.config_entries.async_forward_entry_unload(self.config, domain)
|
||||
for domain in get_domains(self.api.type)
|
||||
)
|
||||
results = await asyncio.gather(*tasks)
|
||||
return all(results)
|
||||
|
||||
async def async_auth(self):
|
||||
"""Authenticate to the device."""
|
||||
try:
|
||||
await self.hass.async_add_executor_job(self.api.auth)
|
||||
except BroadlinkException as err_msg:
|
||||
if self.available:
|
||||
self.available = False
|
||||
_LOGGER.warning(
|
||||
"Disconnected from device at %s: %s", self.api.host[0], err_msg
|
||||
)
|
||||
except (BroadlinkException, OSError) as err:
|
||||
_LOGGER.debug(
|
||||
"Failed to authenticate to the device at %s: %s", self.api.host[0], err
|
||||
)
|
||||
if isinstance(err, AuthenticationError):
|
||||
await self._async_handle_auth_error()
|
||||
return False
|
||||
else:
|
||||
if not self.available:
|
||||
if self.available is not None:
|
||||
_LOGGER.warning("Connected to device at %s", self.api.host[0])
|
||||
self.available = True
|
||||
return True
|
||||
return True
|
||||
|
||||
async def async_request(self, function, *args, **kwargs):
|
||||
"""Send a request to the device."""
|
||||
partial_function = partial(function, *args, **kwargs)
|
||||
for attempt in range(DEFAULT_RETRY):
|
||||
try:
|
||||
result = await self.hass.async_add_executor_job(partial_function)
|
||||
except (AuthorizationError, ConnectionClosedError, DeviceOfflineError):
|
||||
if attempt == DEFAULT_RETRY - 1 or not await self.async_connect():
|
||||
raise
|
||||
else:
|
||||
if not self.available:
|
||||
self.available = True
|
||||
_LOGGER.warning("Connected to device at %s", self.api.host[0])
|
||||
return result
|
||||
request = partial(function, *args, **kwargs)
|
||||
try:
|
||||
return await self.hass.async_add_executor_job(request)
|
||||
except (AuthorizationError, ConnectionClosedError):
|
||||
if not await self.async_auth():
|
||||
raise
|
||||
return await self.hass.async_add_executor_job(request)
|
||||
|
||||
async def _async_handle_auth_error(self):
|
||||
"""Handle an authentication error."""
|
||||
if self.authorized is False:
|
||||
return
|
||||
|
||||
self.authorized = False
|
||||
|
||||
_LOGGER.error(
|
||||
"The device at %s is locked for authentication. Follow the configuration flow to unlock it",
|
||||
self.config.data[CONF_HOST],
|
||||
)
|
||||
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": "reauth"},
|
||||
data={CONF_NAME: self.name, **self.config.data},
|
||||
)
|
||||
)
|
||||
|
|
49
homeassistant/components/broadlink/helpers.py
Normal file
49
homeassistant/components/broadlink/helpers.py
Normal file
|
@ -0,0 +1,49 @@
|
|||
"""Helper functions for the Broadlink integration."""
|
||||
from base64 import b64decode
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_HOST
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
def data_packet(value):
|
||||
"""Decode a data packet given for a Broadlink remote."""
|
||||
value = cv.string(value)
|
||||
extra = len(value) % 4
|
||||
if extra > 0:
|
||||
value = value + ("=" * (4 - extra))
|
||||
return b64decode(value)
|
||||
|
||||
|
||||
def mac_address(mac):
|
||||
"""Validate and convert a MAC address to bytes."""
|
||||
mac = cv.string(mac)
|
||||
if len(mac) == 17:
|
||||
mac = "".join(mac[i : i + 2] for i in range(0, 17, 3))
|
||||
elif len(mac) == 14:
|
||||
mac = "".join(mac[i : i + 4] for i in range(0, 14, 5))
|
||||
elif len(mac) != 12:
|
||||
raise ValueError("Invalid MAC address")
|
||||
return bytes.fromhex(mac)
|
||||
|
||||
|
||||
def format_mac(mac):
|
||||
"""Format a MAC address."""
|
||||
return ":".join([format(octet, "02x") for octet in mac])
|
||||
|
||||
|
||||
def import_device(hass, host):
|
||||
"""Create a config flow for a device."""
|
||||
configured_hosts = {
|
||||
entry.data.get(CONF_HOST) for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
}
|
||||
|
||||
if host not in configured_hosts:
|
||||
task = hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data={CONF_HOST: host},
|
||||
)
|
||||
hass.async_create_task(task)
|
|
@ -2,6 +2,7 @@
|
|||
"domain": "broadlink",
|
||||
"name": "Broadlink",
|
||||
"documentation": "https://www.home-assistant.io/integrations/broadlink",
|
||||
"requirements": ["broadlink==0.14.0"],
|
||||
"codeowners": ["@danielhiversen", "@felipediel"]
|
||||
"requirements": ["broadlink==0.14.1"],
|
||||
"codeowners": ["@danielhiversen", "@felipediel"],
|
||||
"config_flow": true
|
||||
}
|
||||
|
|
|
@ -1,14 +1,11 @@
|
|||
"""Support for Broadlink IR/RF remotes."""
|
||||
"""Support for Broadlink 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,
|
||||
|
@ -25,131 +22,108 @@ from homeassistant.components.remote import (
|
|||
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.const import CONF_HOST, STATE_ON
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
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
|
||||
from .const import DOMAIN
|
||||
from .helpers import data_packet, import_device
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=2)
|
||||
LEARNING_TIMEOUT = timedelta(seconds=30)
|
||||
|
||||
CODE_STORAGE_VERSION = 1
|
||||
FLAG_STORAGE_VERSION = 1
|
||||
FLAG_SAVE_DELAY = 15
|
||||
|
||||
DEVICE_TYPES = RM_TYPES + RM4_TYPES
|
||||
|
||||
MINIMUM_SERVICE_SCHEMA = vol.Schema(
|
||||
COMMAND_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_SEND_SCHEMA = COMMAND_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(ATTR_DEVICE): vol.All(cv.string, vol.Length(min=1)),
|
||||
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}
|
||||
SERVICE_LEARN_SCHEMA = COMMAND_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE): vol.All(cv.string, vol.Length(min=1)),
|
||||
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,
|
||||
}
|
||||
{vol.Required(CONF_HOST): cv.string}, extra=vol.ALLOW_EXTRA
|
||||
)
|
||||
|
||||
|
||||
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')}"
|
||||
"""Import the device and discontinue platform.
|
||||
|
||||
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()
|
||||
This is for backward compatibility.
|
||||
Do not use this method.
|
||||
"""
|
||||
import_device(hass, config[CONF_HOST])
|
||||
_LOGGER.warning(
|
||||
"The remote platform is deprecated, please remove it from your configuration"
|
||||
)
|
||||
if not connected:
|
||||
hass.data[DOMAIN][COMPONENT].remove(unique_id)
|
||||
raise PlatformNotReady
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up a Broadlink remote."""
|
||||
device = hass.data[DOMAIN].devices[config_entry.entry_id]
|
||||
remote = BroadlinkRemote(
|
||||
device,
|
||||
Store(hass, CODE_STORAGE_VERSION, f"broadlink_remote_{device.unique_id}_codes"),
|
||||
Store(hass, FLAG_STORAGE_VERSION, f"broadlink_remote_{device.unique_id}_flags"),
|
||||
)
|
||||
|
||||
loaded = await remote.async_load_storage_files()
|
||||
if not loaded:
|
||||
_LOGGER.error("Failed to set up %s", unique_id)
|
||||
hass.data[DOMAIN][COMPONENT].remove(unique_id)
|
||||
_LOGGER.error("Failed to create '%s Remote' entity: Storage error", device.name)
|
||||
return
|
||||
|
||||
async_add_entities([remote], False)
|
||||
|
||||
|
||||
class BroadlinkRemote(RemoteEntity):
|
||||
class BroadlinkRemote(RemoteEntity, RestoreEntity):
|
||||
"""Representation of a Broadlink remote."""
|
||||
|
||||
def __init__(self, name, unique_id, device, code_storage, flag_storage):
|
||||
def __init__(self, device, codes, flags):
|
||||
"""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._device = device
|
||||
self._coordinator = device.update_manager.coordinator
|
||||
self._code_storage = codes
|
||||
self._flag_storage = flags
|
||||
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
|
||||
return f"{self._device.name} Remote"
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the unique ID of the remote."""
|
||||
return self._unique_id
|
||||
"""Return the unique id of the remote."""
|
||||
return self._device.unique_id
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
|
@ -159,42 +133,104 @@ class BroadlinkRemote(RemoteEntity):
|
|||
@property
|
||||
def available(self):
|
||||
"""Return True if the remote is available."""
|
||||
return self.device.available
|
||||
return self._device.update_manager.available
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return True if the remote has to be polled for state."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag supported features."""
|
||||
return SUPPORT_LEARN_COMMAND
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return device info."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self._device.unique_id)},
|
||||
"manufacturer": self._device.api.manufacturer,
|
||||
"model": self._device.api.model,
|
||||
"name": self._device.name,
|
||||
"sw_version": self._device.fw_version,
|
||||
}
|
||||
|
||||
def get_code(self, command, device):
|
||||
"""Return a code and a boolean indicating a toggle command.
|
||||
|
||||
If the command starts with `b64:`, extract the code from it.
|
||||
Otherwise, extract the code from the dictionary, using the device
|
||||
and command as keys.
|
||||
|
||||
You need to change the flag whenever a toggle command is sent
|
||||
successfully. Use `self._flags[device] ^= 1`.
|
||||
"""
|
||||
if command.startswith("b64:"):
|
||||
code, is_toggle_cmd = command[4:], False
|
||||
|
||||
else:
|
||||
if device is None:
|
||||
raise KeyError("You need to specify a device")
|
||||
|
||||
try:
|
||||
code = self._codes[device][command]
|
||||
except KeyError:
|
||||
raise KeyError("Command not found")
|
||||
|
||||
# For toggle commands, alternate between codes in a list.
|
||||
if isinstance(code, list):
|
||||
code = code[self._flags[device]]
|
||||
is_toggle_cmd = True
|
||||
else:
|
||||
is_toggle_cmd = False
|
||||
|
||||
try:
|
||||
return data_packet(code), is_toggle_cmd
|
||||
except ValueError:
|
||||
raise ValueError("Invalid code")
|
||||
|
||||
@callback
|
||||
def get_flags(self):
|
||||
"""Return dictionary of toggle flags.
|
||||
"""Return a dictionary of toggle flags.
|
||||
|
||||
A toggle flag indicates whether `self._async_send_code()`
|
||||
should send an alternative code for a key device.
|
||||
A toggle flag indicates whether the remote should send an
|
||||
alternative code.
|
||||
"""
|
||||
return self._flags
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Turn the remote on."""
|
||||
self._state = True
|
||||
async def async_added_to_hass(self):
|
||||
"""Call when the remote is added to hass."""
|
||||
state = await self.async_get_last_state()
|
||||
self._state = state is None or state.state == STATE_ON
|
||||
|
||||
async def async_turn_off(self, **kwargs):
|
||||
"""Turn the remote off."""
|
||||
self._state = False
|
||||
self.async_on_remove(
|
||||
self._coordinator.async_add_listener(self.async_write_ha_state)
|
||||
)
|
||||
|
||||
async def async_update(self):
|
||||
"""Update the availability of the device."""
|
||||
if not self.available:
|
||||
await self.device.async_connect()
|
||||
"""Update the remote."""
|
||||
await self._coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Turn on the remote."""
|
||||
self._state = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs):
|
||||
"""Turn off the remote."""
|
||||
self._state = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
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):
|
||||
|
@ -202,7 +238,7 @@ class BroadlinkRemote(RemoteEntity):
|
|||
kwargs[ATTR_COMMAND] = command
|
||||
kwargs = SERVICE_SEND_SCHEMA(kwargs)
|
||||
commands = kwargs[ATTR_COMMAND]
|
||||
device = kwargs[ATTR_DEVICE]
|
||||
device = kwargs.get(ATTR_DEVICE)
|
||||
repeat = kwargs[ATTR_NUM_REPEATS]
|
||||
delay = kwargs[ATTR_DELAY_SECS]
|
||||
|
||||
|
@ -210,53 +246,37 @@ class BroadlinkRemote(RemoteEntity):
|
|||
return
|
||||
|
||||
should_delay = False
|
||||
|
||||
for _, cmd in product(range(repeat), commands):
|
||||
if should_delay:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
try:
|
||||
should_delay = await self._async_send_code(
|
||||
cmd, device, delay if should_delay else 0
|
||||
)
|
||||
except (AuthorizationError, DeviceOfflineError):
|
||||
code, is_toggle_cmd = self.get_code(cmd, device)
|
||||
|
||||
except (KeyError, ValueError) as err:
|
||||
_LOGGER.error("Failed to send '%s': %s", cmd, err)
|
||||
should_delay = False
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._device.async_request(self._device.api.send_data, code)
|
||||
|
||||
except (AuthorizationError, DeviceOfflineError, OSError) as err:
|
||||
_LOGGER.error("Failed to send '%s': %s", command, err)
|
||||
break
|
||||
except BroadlinkException:
|
||||
pass
|
||||
|
||||
except BroadlinkException as err:
|
||||
_LOGGER.error("Failed to send '%s': %s", command, err)
|
||||
should_delay = False
|
||||
continue
|
||||
|
||||
should_delay = True
|
||||
if is_toggle_cmd:
|
||||
self._flags[device] ^= 1
|
||||
|
||||
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)
|
||||
|
@ -268,20 +288,23 @@ class BroadlinkRemote(RemoteEntity):
|
|||
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)
|
||||
|
||||
except (AuthorizationError, DeviceOfflineError, OSError) as err:
|
||||
_LOGGER.error("Failed to learn '%s': %s", command, err)
|
||||
break
|
||||
except (BroadlinkException, TimeoutError) as err_msg:
|
||||
_LOGGER.error("Failed to learn '%s': %s", command, err_msg)
|
||||
|
||||
except BroadlinkException as err:
|
||||
_LOGGER.error("Failed to learn '%s': %s", command, err)
|
||||
continue
|
||||
else:
|
||||
self._codes.setdefault(device, {}).update({command: code})
|
||||
should_store = True
|
||||
|
||||
self._codes.setdefault(device, {}).update({command: code})
|
||||
should_store = True
|
||||
|
||||
if should_store:
|
||||
await self._code_storage.async_save(self._codes)
|
||||
|
@ -289,9 +312,10 @@ class BroadlinkRemote(RemoteEntity):
|
|||
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)
|
||||
await self._device.async_request(self._device.api.enter_learning)
|
||||
|
||||
except (BroadlinkException, OSError) as err:
|
||||
_LOGGER.debug("Failed to enter learning mode: %s", err)
|
||||
raise
|
||||
|
||||
self.hass.components.persistent_notification.async_create(
|
||||
|
@ -305,11 +329,14 @@ class BroadlinkRemote(RemoteEntity):
|
|||
while (utcnow() - start_time) < LEARNING_TIMEOUT:
|
||||
await asyncio.sleep(1)
|
||||
try:
|
||||
code = await self.device.async_request(self.device.api.check_data)
|
||||
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"
|
||||
|
|
|
@ -1,115 +1,80 @@
|
|||
"""Support for the Broadlink RM2 Pro (only temperature) and A1 devices."""
|
||||
from datetime import timedelta
|
||||
from ipaddress import ip_address
|
||||
"""Support for Broadlink sensors."""
|
||||
import logging
|
||||
|
||||
import broadlink as blk
|
||||
from broadlink.exceptions import BroadlinkException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import PLATFORM_SCHEMA
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_MAC,
|
||||
CONF_MONITORED_CONDITIONS,
|
||||
CONF_NAME,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_TIMEOUT,
|
||||
CONF_TYPE,
|
||||
TEMP_CELSIUS,
|
||||
UNIT_PERCENTAGE,
|
||||
from homeassistant.components.sensor import (
|
||||
DEVICE_CLASS_HUMIDITY,
|
||||
DEVICE_CLASS_ILLUMINANCE,
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
PLATFORM_SCHEMA,
|
||||
)
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.const import CONF_HOST, TEMP_CELSIUS, UNIT_PERCENTAGE
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from . import hostname, mac_address
|
||||
from .const import (
|
||||
A1_TYPES,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_RETRY,
|
||||
DEFAULT_TIMEOUT,
|
||||
RM4_TYPES,
|
||||
RM_TYPES,
|
||||
)
|
||||
from .device import BroadlinkDevice
|
||||
from .const import DOMAIN
|
||||
from .helpers import import_device
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=300)
|
||||
|
||||
SENSOR_TYPES = {
|
||||
"temperature": ["Temperature", TEMP_CELSIUS],
|
||||
"air_quality": ["Air Quality", " "],
|
||||
"humidity": ["Humidity", UNIT_PERCENTAGE],
|
||||
"light": ["Light", " "],
|
||||
"noise": ["Noise", " "],
|
||||
"temperature": ("Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE),
|
||||
"air_quality": ("Air Quality", None, None),
|
||||
"humidity": ("Humidity", UNIT_PERCENTAGE, DEVICE_CLASS_HUMIDITY),
|
||||
"light": ("Light", None, DEVICE_CLASS_ILLUMINANCE),
|
||||
"noise": ("Noise", None, None),
|
||||
}
|
||||
|
||||
DEVICE_TYPES = A1_TYPES + RM_TYPES + RM4_TYPES
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): vol.Coerce(str),
|
||||
vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): vol.All(
|
||||
cv.ensure_list, [vol.In(SENSOR_TYPES)]
|
||||
),
|
||||
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.Required(CONF_HOST): cv.string}, extra=vol.ALLOW_EXTRA
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Set up the Broadlink device sensors."""
|
||||
host = config[CONF_HOST]
|
||||
mac_addr = config[CONF_MAC]
|
||||
model = config[CONF_TYPE]
|
||||
name = config[CONF_NAME]
|
||||
timeout = config[CONF_TIMEOUT]
|
||||
update_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
|
||||
"""Import the device and discontinue platform.
|
||||
|
||||
if model in RM4_TYPES:
|
||||
api = blk.rm4((host, DEFAULT_PORT), mac_addr, None)
|
||||
check_sensors = api.check_sensors
|
||||
else:
|
||||
api = blk.a1((host, DEFAULT_PORT), mac_addr, None)
|
||||
check_sensors = api.check_sensors_raw
|
||||
This is for backward compatibility.
|
||||
Do not use this method.
|
||||
"""
|
||||
import_device(hass, config[CONF_HOST])
|
||||
_LOGGER.warning(
|
||||
"The sensor platform is deprecated, please remove it from your configuration"
|
||||
)
|
||||
|
||||
api.timeout = timeout
|
||||
device = BroadlinkDevice(hass, api)
|
||||
|
||||
connected = await device.async_connect()
|
||||
if not connected:
|
||||
raise PlatformNotReady
|
||||
|
||||
broadlink_data = BroadlinkData(device, check_sensors, update_interval)
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up the Broadlink sensor."""
|
||||
device = hass.data[DOMAIN].devices[config_entry.entry_id]
|
||||
sensor_data = device.update_manager.coordinator.data
|
||||
sensors = [
|
||||
BroadlinkSensor(name, broadlink_data, variable)
|
||||
for variable in config[CONF_MONITORED_CONDITIONS]
|
||||
BroadlinkSensor(device, monitored_condition)
|
||||
for monitored_condition in sensor_data
|
||||
if sensor_data[monitored_condition] or device.api.type == "A1"
|
||||
]
|
||||
async_add_entities(sensors, True)
|
||||
async_add_entities(sensors)
|
||||
|
||||
|
||||
class BroadlinkSensor(Entity):
|
||||
"""Representation of a Broadlink device sensor."""
|
||||
"""Representation of a Broadlink sensor."""
|
||||
|
||||
def __init__(self, name, broadlink_data, sensor_type):
|
||||
def __init__(self, device, monitored_condition):
|
||||
"""Initialize the sensor."""
|
||||
self._name = f"{name} {SENSOR_TYPES[sensor_type][0]}"
|
||||
self._state = None
|
||||
self._type = sensor_type
|
||||
self._broadlink_data = broadlink_data
|
||||
self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
|
||||
self._device = device
|
||||
self._coordinator = device.update_manager.coordinator
|
||||
self._monitored_condition = monitored_condition
|
||||
self._state = self._coordinator.data[monitored_condition]
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the unique id of the sensor."""
|
||||
return f"{self._device.unique_id}-{self._monitored_condition}"
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
return f"{self._device.name} {SENSOR_TYPES[self._monitored_condition][0]}"
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
|
@ -118,52 +83,46 @@ class BroadlinkSensor(Entity):
|
|||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return True if entity is available."""
|
||||
return self._broadlink_data.device.available
|
||||
"""Return True if the sensor is available."""
|
||||
return self._device.update_manager.available
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
"""Return the unit this state is expressed in."""
|
||||
return self._unit_of_measurement
|
||||
"""Return the unit of measurement of the sensor."""
|
||||
return SENSOR_TYPES[self._monitored_condition][1]
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return True if the sensor has to be polled for state."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return device class."""
|
||||
return SENSOR_TYPES[self._monitored_condition][2]
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return device info."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self._device.unique_id)},
|
||||
"manufacturer": self._device.api.manufacturer,
|
||||
"model": self._device.api.model,
|
||||
"name": self._device.name,
|
||||
"sw_version": self._device.fw_version,
|
||||
}
|
||||
|
||||
@callback
|
||||
def update_data(self):
|
||||
"""Update data."""
|
||||
if self._coordinator.last_update_success:
|
||||
self._state = self._coordinator.data[self._monitored_condition]
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Call when the sensor is added to hass."""
|
||||
self.async_on_remove(self._coordinator.async_add_listener(self.update_data))
|
||||
|
||||
async def async_update(self):
|
||||
"""Get the latest data from the sensor."""
|
||||
await self._broadlink_data.async_update()
|
||||
self._state = self._broadlink_data.data.get(self._type)
|
||||
|
||||
|
||||
class BroadlinkData:
|
||||
"""Representation of a Broadlink data object."""
|
||||
|
||||
def __init__(self, device, check_sensors, interval):
|
||||
"""Initialize the data object."""
|
||||
self.device = device
|
||||
self.check_sensors = check_sensors
|
||||
self.data = {}
|
||||
self._schema = vol.Schema(
|
||||
{
|
||||
vol.Optional("temperature"): vol.Range(min=-50, max=150),
|
||||
vol.Optional("humidity"): vol.Range(min=0, max=100),
|
||||
vol.Optional("light"): vol.Any(0, 1, 2, 3),
|
||||
vol.Optional("air_quality"): vol.Any(0, 1, 2, 3),
|
||||
vol.Optional("noise"): vol.Any(0, 1, 2),
|
||||
}
|
||||
)
|
||||
self.async_update = Throttle(interval)(self._async_fetch_data)
|
||||
|
||||
async def _async_fetch_data(self):
|
||||
"""Fetch sensor data."""
|
||||
for _ in range(DEFAULT_RETRY):
|
||||
try:
|
||||
data = await self.device.async_request(self.check_sensors)
|
||||
except BroadlinkException:
|
||||
return
|
||||
try:
|
||||
data = self._schema(data)
|
||||
except (vol.Invalid, vol.MultipleInvalid):
|
||||
continue
|
||||
else:
|
||||
self.data = data
|
||||
return
|
||||
|
||||
_LOGGER.debug("Failed to update sensors: Device returned malformed data")
|
||||
"""Update the sensor."""
|
||||
await self._coordinator.async_request_refresh()
|
||||
|
|
46
homeassistant/components/broadlink/strings.json
Normal file
46
homeassistant/components/broadlink/strings.json
Normal file
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"config": {
|
||||
"flow_title": "{name} ({model} at {host})",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to the device",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"timeout": "Timeout"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authenticate to the device"
|
||||
},
|
||||
"reset": {
|
||||
"title": "Unlock the device",
|
||||
"description": "Your device is locked for authentication. Follow the instructions to unlock it:\n1. Factory reset the device.\n2. Use the official app to add the device to your local network.\n3. Stop. Do not finish the setup. Close the app.\n4. Click Submit."
|
||||
},
|
||||
"unlock": {
|
||||
"title": "Unlock the device (optional)",
|
||||
"description": "Your device is locked. This can lead to authentication problems in Home Assistant. Would you like to unlock it?",
|
||||
"data": {
|
||||
"unlock": "Yes, do it."
|
||||
}
|
||||
},
|
||||
"finish": {
|
||||
"title": "Choose a name for the device",
|
||||
"data": {
|
||||
"name": "Name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "There is already a configuration flow in progress for this device",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_host": "Invalid hostname or IP address",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_host": "Invalid hostname or IP address",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,52 +1,48 @@
|
|||
"""Support for Broadlink RM devices."""
|
||||
from datetime import timedelta
|
||||
from ipaddress import ip_address
|
||||
"""Support for Broadlink switches."""
|
||||
from abc import ABC, abstractmethod
|
||||
import logging
|
||||
|
||||
import broadlink as blk
|
||||
from broadlink.exceptions import BroadlinkException, CommandNotSupportedError
|
||||
from broadlink.exceptions import BroadlinkException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.switch import DOMAIN, PLATFORM_SCHEMA, SwitchEntity
|
||||
from homeassistant.components.switch import (
|
||||
DEVICE_CLASS_OUTLET,
|
||||
DEVICE_CLASS_SWITCH,
|
||||
PLATFORM_SCHEMA,
|
||||
SwitchEntity,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_COMMAND_OFF,
|
||||
CONF_COMMAND_ON,
|
||||
CONF_FRIENDLY_NAME,
|
||||
CONF_HOST,
|
||||
CONF_MAC,
|
||||
CONF_NAME,
|
||||
CONF_SWITCHES,
|
||||
CONF_TIMEOUT,
|
||||
CONF_TYPE,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.core import callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.util import Throttle, slugify
|
||||
|
||||
from . import async_setup_service, data_packet, hostname, mac_address
|
||||
from .const import (
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_TIMEOUT,
|
||||
MP1_TYPES,
|
||||
RM4_TYPES,
|
||||
RM_TYPES,
|
||||
SP1_TYPES,
|
||||
SP2_TYPES,
|
||||
)
|
||||
from .device import BroadlinkDevice
|
||||
from .const import DOMAIN, SWITCH_DOMAIN
|
||||
from .helpers import data_packet, import_device, mac_address
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TIME_BETWEEN_UPDATES = timedelta(seconds=5)
|
||||
|
||||
CONF_SLOTS = "slots"
|
||||
CONF_RETRY = "retry"
|
||||
|
||||
DEVICE_TYPES = RM_TYPES + RM4_TYPES + SP1_TYPES + SP2_TYPES + MP1_TYPES
|
||||
|
||||
SWITCH_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_COMMAND_OFF): data_packet,
|
||||
vol.Optional(CONF_COMMAND_ON): data_packet,
|
||||
}
|
||||
)
|
||||
|
||||
OLD_SWITCH_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_COMMAND_OFF): data_packet,
|
||||
vol.Optional(CONF_COMMAND_ON): data_packet,
|
||||
|
@ -54,288 +50,289 @@ SWITCH_SCHEMA = vol.Schema(
|
|||
}
|
||||
)
|
||||
|
||||
MP1_SWITCH_SLOT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("slot_1"): cv.string,
|
||||
vol.Optional("slot_2"): cv.string,
|
||||
vol.Optional("slot_3"): cv.string,
|
||||
vol.Optional("slot_4"): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_SWITCHES, default={}): cv.schema_with_slug_keys(
|
||||
SWITCH_SCHEMA
|
||||
),
|
||||
vol.Optional(CONF_SLOTS, default={}): MP1_SWITCH_SLOT_SCHEMA,
|
||||
vol.Required(CONF_HOST): vol.All(vol.Any(hostname, ip_address), cv.string),
|
||||
vol.Required(CONF_MAC): mac_address,
|
||||
vol.Optional(CONF_FRIENDLY_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_TYPE, default=DEVICE_TYPES[0]): vol.In(DEVICE_TYPES),
|
||||
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
|
||||
}
|
||||
PLATFORM_SCHEMA = vol.All(
|
||||
cv.deprecated(CONF_HOST),
|
||||
cv.deprecated(CONF_SLOTS),
|
||||
cv.deprecated(CONF_TIMEOUT),
|
||||
cv.deprecated(CONF_TYPE),
|
||||
PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_MAC): mac_address,
|
||||
vol.Optional(CONF_HOST): cv.string,
|
||||
vol.Optional(CONF_SWITCHES, default=[]): vol.Any(
|
||||
cv.schema_with_slug_keys(OLD_SWITCH_SCHEMA),
|
||||
vol.All(cv.ensure_list, [SWITCH_SCHEMA]),
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Set up the Broadlink switches."""
|
||||
"""Import the device and set up custom switches.
|
||||
|
||||
host = config[CONF_HOST]
|
||||
This is for backward compatibility.
|
||||
Do not use this method.
|
||||
"""
|
||||
mac_addr = config[CONF_MAC]
|
||||
friendly_name = config[CONF_FRIENDLY_NAME]
|
||||
model = config[CONF_TYPE]
|
||||
timeout = config[CONF_TIMEOUT]
|
||||
slots = config[CONF_SLOTS]
|
||||
devices = config[CONF_SWITCHES]
|
||||
host = config.get(CONF_HOST)
|
||||
switches = config.get(CONF_SWITCHES)
|
||||
|
||||
def generate_rm_switches(switches, broadlink_device):
|
||||
"""Generate RM switches."""
|
||||
return [
|
||||
BroadlinkRMSwitch(
|
||||
object_id,
|
||||
config.get(CONF_FRIENDLY_NAME, object_id),
|
||||
broadlink_device,
|
||||
config.get(CONF_COMMAND_ON),
|
||||
config.get(CONF_COMMAND_OFF),
|
||||
)
|
||||
for object_id, config in switches.items()
|
||||
]
|
||||
|
||||
def get_mp1_slot_name(switch_friendly_name, slot):
|
||||
"""Get slot name."""
|
||||
if not slots[f"slot_{slot}"]:
|
||||
return f"{switch_friendly_name} slot {slot}"
|
||||
return slots[f"slot_{slot}"]
|
||||
|
||||
if model in RM_TYPES:
|
||||
api = blk.rm((host, DEFAULT_PORT), mac_addr, None)
|
||||
broadlink_device = BroadlinkDevice(hass, api)
|
||||
switches = generate_rm_switches(devices, broadlink_device)
|
||||
elif model in RM4_TYPES:
|
||||
api = blk.rm4((host, DEFAULT_PORT), mac_addr, None)
|
||||
broadlink_device = BroadlinkDevice(hass, api)
|
||||
switches = generate_rm_switches(devices, broadlink_device)
|
||||
elif model in SP1_TYPES:
|
||||
api = blk.sp1((host, DEFAULT_PORT), mac_addr, None)
|
||||
broadlink_device = BroadlinkDevice(hass, api)
|
||||
switches = [BroadlinkSP1Switch(friendly_name, broadlink_device)]
|
||||
elif model in SP2_TYPES:
|
||||
api = blk.sp2((host, DEFAULT_PORT), mac_addr, None)
|
||||
broadlink_device = BroadlinkDevice(hass, api)
|
||||
switches = [BroadlinkSP2Switch(friendly_name, broadlink_device)]
|
||||
elif model in MP1_TYPES:
|
||||
api = blk.mp1((host, DEFAULT_PORT), mac_addr, None)
|
||||
broadlink_device = BroadlinkDevice(hass, api)
|
||||
parent_device = BroadlinkMP1Switch(broadlink_device)
|
||||
if not isinstance(switches, list):
|
||||
switches = [
|
||||
BroadlinkMP1Slot(
|
||||
get_mp1_slot_name(friendly_name, i), broadlink_device, i, parent_device,
|
||||
)
|
||||
for i in range(1, 5)
|
||||
{CONF_NAME: switch.pop(CONF_FRIENDLY_NAME, name), **switch}
|
||||
for name, switch in switches.items()
|
||||
]
|
||||
|
||||
api.timeout = timeout
|
||||
connected = await broadlink_device.async_connect()
|
||||
if not connected:
|
||||
raise PlatformNotReady
|
||||
_LOGGER.warning(
|
||||
"Your configuration for the switch platform is deprecated. "
|
||||
"Please refer to the Broadlink documentation to catch up"
|
||||
)
|
||||
|
||||
if model in RM_TYPES or model in RM4_TYPES:
|
||||
hass.async_create_task(async_setup_service(hass, host, broadlink_device))
|
||||
if switches:
|
||||
platform_data = hass.data[DOMAIN].platforms.setdefault(SWITCH_DOMAIN, {})
|
||||
platform_data.setdefault(mac_addr, []).extend(switches)
|
||||
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"The switch platform is deprecated, except for custom IR/RF "
|
||||
"switches. Please refer to the Broadlink documentation to "
|
||||
"catch up"
|
||||
)
|
||||
|
||||
if host:
|
||||
import_device(hass, host)
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up the Broadlink switch."""
|
||||
device = hass.data[DOMAIN].devices[config_entry.entry_id]
|
||||
|
||||
if device.api.type in {"RM2", "RM4"}:
|
||||
platform_data = hass.data[DOMAIN].platforms.get(SWITCH_DOMAIN, {})
|
||||
user_defined_switches = platform_data.get(device.api.mac, {})
|
||||
switches = [
|
||||
BroadlinkRMSwitch(device, config) for config in user_defined_switches
|
||||
]
|
||||
|
||||
elif device.api.type == "SP1":
|
||||
switches = [BroadlinkSP1Switch(device)]
|
||||
|
||||
elif device.api.type == "SP2":
|
||||
switches = [BroadlinkSP2Switch(device)]
|
||||
|
||||
elif device.api.type == "MP1":
|
||||
switches = [BroadlinkMP1Slot(device, slot) for slot in range(1, 5)]
|
||||
|
||||
async_add_entities(switches)
|
||||
|
||||
|
||||
class BroadlinkRMSwitch(SwitchEntity, RestoreEntity):
|
||||
"""Representation of an Broadlink switch."""
|
||||
class BroadlinkSwitch(SwitchEntity, RestoreEntity, ABC):
|
||||
"""Representation of a Broadlink switch."""
|
||||
|
||||
def __init__(self, name, friendly_name, device, command_on, command_off):
|
||||
def __init__(self, device, command_on, command_off):
|
||||
"""Initialize the switch."""
|
||||
self.device = device
|
||||
self.entity_id = f"{DOMAIN}.{slugify(name)}"
|
||||
self._name = friendly_name
|
||||
self._state = False
|
||||
self._device = device
|
||||
self._command_on = command_on
|
||||
self._command_off = command_off
|
||||
self._coordinator = device.update_manager.coordinator
|
||||
self._device_class = None
|
||||
self._state = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the switch."""
|
||||
return f"{self._device.name} Switch"
|
||||
|
||||
@property
|
||||
def assumed_state(self):
|
||||
"""Return True if unable to access real state of the switch."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return True if the switch is available."""
|
||||
return self._device.update_manager.available
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return True if the switch is on."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return True if the switch has to be polled for state."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return device class."""
|
||||
return self._device_class
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return device info."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self._device.unique_id)},
|
||||
"manufacturer": self._device.api.manufacturer,
|
||||
"model": self._device.api.model,
|
||||
"name": self._device.name,
|
||||
"sw_version": self._device.fw_version,
|
||||
}
|
||||
|
||||
@callback
|
||||
def update_data(self):
|
||||
"""Update data."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Call when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
state = await self.async_get_last_state()
|
||||
if state:
|
||||
self._state = state.state == STATE_ON
|
||||
"""Call when the switch is added to hass."""
|
||||
if self._state is None:
|
||||
state = await self.async_get_last_state()
|
||||
self._state = state is not None and state.state == STATE_ON
|
||||
self.async_on_remove(self._coordinator.async_add_listener(self.update_data))
|
||||
|
||||
async def async_update(self):
|
||||
"""Update the switch."""
|
||||
await self._coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Turn on the switch."""
|
||||
if await self._async_send_packet(self._command_on):
|
||||
self._state = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs):
|
||||
"""Turn off the switch."""
|
||||
if await self._async_send_packet(self._command_off):
|
||||
self._state = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
@abstractmethod
|
||||
async def _async_send_packet(self, packet):
|
||||
"""Send a packet to the device."""
|
||||
|
||||
|
||||
class BroadlinkRMSwitch(BroadlinkSwitch):
|
||||
"""Representation of a Broadlink RM switch."""
|
||||
|
||||
def __init__(self, device, config):
|
||||
"""Initialize the switch."""
|
||||
super().__init__(
|
||||
device, config.get(CONF_COMMAND_ON), config.get(CONF_COMMAND_OFF)
|
||||
)
|
||||
self._name = config[CONF_NAME]
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the switch."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def assumed_state(self):
|
||||
"""Return true if unable to access real state of entity."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return True if entity is available."""
|
||||
return not self.should_poll or self.device.available
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return the polling state."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if device is on."""
|
||||
return self._state
|
||||
|
||||
async def async_update(self):
|
||||
"""Update the state of the device."""
|
||||
if not self.available:
|
||||
await self.device.async_connect()
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Turn the device on."""
|
||||
if await self._async_send_packet(self._command_on):
|
||||
self._state = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs):
|
||||
"""Turn the device off."""
|
||||
if await self._async_send_packet(self._command_off):
|
||||
self._state = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _async_send_packet(self, packet):
|
||||
"""Send packet to device."""
|
||||
"""Send a packet to the device."""
|
||||
if packet is None:
|
||||
_LOGGER.debug("Empty packet")
|
||||
return True
|
||||
|
||||
try:
|
||||
await self.device.async_request(self.device.api.send_data, packet)
|
||||
except BroadlinkException as err_msg:
|
||||
_LOGGER.error("Failed to send packet: %s", err_msg)
|
||||
await self._device.async_request(self._device.api.send_data, packet)
|
||||
except (BroadlinkException, OSError) as err:
|
||||
_LOGGER.error("Failed to send packet: %s", err)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class BroadlinkSP1Switch(BroadlinkRMSwitch):
|
||||
"""Representation of an Broadlink switch."""
|
||||
class BroadlinkSP1Switch(BroadlinkSwitch):
|
||||
"""Representation of a Broadlink SP1 switch."""
|
||||
|
||||
def __init__(self, friendly_name, device):
|
||||
def __init__(self, device):
|
||||
"""Initialize the switch."""
|
||||
super().__init__(friendly_name, friendly_name, device, None, None)
|
||||
self._command_on = 1
|
||||
self._command_off = 0
|
||||
self._load_power = None
|
||||
super().__init__(device, 1, 0)
|
||||
self._device_class = DEVICE_CLASS_OUTLET
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the unique id of the switch."""
|
||||
return self._device.unique_id
|
||||
|
||||
async def _async_send_packet(self, packet):
|
||||
"""Send packet to device."""
|
||||
"""Send a packet to the device."""
|
||||
try:
|
||||
await self.device.async_request(self.device.api.set_power, packet)
|
||||
except BroadlinkException as err_msg:
|
||||
_LOGGER.error("Failed to send packet: %s", err_msg)
|
||||
await self._device.async_request(self._device.api.set_power, packet)
|
||||
except (BroadlinkException, OSError) as err:
|
||||
_LOGGER.error("Failed to send packet: %s", err)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class BroadlinkSP2Switch(BroadlinkSP1Switch):
|
||||
"""Representation of an Broadlink switch."""
|
||||
"""Representation of a Broadlink SP2 switch."""
|
||||
|
||||
def __init__(self, device, *args, **kwargs):
|
||||
"""Initialize the switch."""
|
||||
super().__init__(device, *args, **kwargs)
|
||||
self._state = self._coordinator.data["state"]
|
||||
self._load_power = self._coordinator.data["load_power"]
|
||||
if device.api.model == "SC1":
|
||||
self._device_class = DEVICE_CLASS_SWITCH
|
||||
|
||||
@property
|
||||
def assumed_state(self):
|
||||
"""Return true if unable to access real state of entity."""
|
||||
"""Return True if unable to access real state of the switch."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return the polling state."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def current_power_w(self):
|
||||
"""Return the current power usage in Watt."""
|
||||
try:
|
||||
return round(self._load_power, 2)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return self._load_power
|
||||
|
||||
async def async_update(self):
|
||||
"""Update the state of the device."""
|
||||
try:
|
||||
self._state = await self.device.async_request(self.device.api.check_power)
|
||||
except BroadlinkException as err_msg:
|
||||
_LOGGER.error("Failed to update state: %s", err_msg)
|
||||
return
|
||||
|
||||
try:
|
||||
self._load_power = await self.device.async_request(
|
||||
self.device.api.get_energy
|
||||
)
|
||||
except CommandNotSupportedError:
|
||||
return
|
||||
except BroadlinkException as err_msg:
|
||||
_LOGGER.error("Failed to update load power: %s", err_msg)
|
||||
@callback
|
||||
def update_data(self):
|
||||
"""Update data."""
|
||||
if self._coordinator.last_update_success:
|
||||
self._state = self._coordinator.data["state"]
|
||||
self._load_power = self._coordinator.data["load_power"]
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class BroadlinkMP1Slot(BroadlinkRMSwitch):
|
||||
"""Representation of a slot of Broadlink switch."""
|
||||
class BroadlinkMP1Slot(BroadlinkSwitch):
|
||||
"""Representation of a Broadlink MP1 slot."""
|
||||
|
||||
def __init__(self, friendly_name, device, slot, parent_device):
|
||||
"""Initialize the slot of switch."""
|
||||
super().__init__(friendly_name, friendly_name, device, None, None)
|
||||
self._command_on = 1
|
||||
self._command_off = 0
|
||||
def __init__(self, device, slot):
|
||||
"""Initialize the switch."""
|
||||
super().__init__(device, 1, 0)
|
||||
self._slot = slot
|
||||
self._parent_device = parent_device
|
||||
self._state = self._coordinator.data[f"s{slot}"]
|
||||
self._device_class = DEVICE_CLASS_OUTLET
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the unique id of the slot."""
|
||||
return f"{self._device.unique_id}-s{self._slot}"
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the switch."""
|
||||
return f"{self._device.name} S{self._slot}"
|
||||
|
||||
@property
|
||||
def assumed_state(self):
|
||||
"""Return true if unable to access real state of entity."""
|
||||
"""Return True if unable to access real state of the switch."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return the polling state."""
|
||||
return True
|
||||
|
||||
async def async_update(self):
|
||||
"""Update the state of the device."""
|
||||
await self._parent_device.async_update()
|
||||
self._state = self._parent_device.get_outlet_status(self._slot)
|
||||
@callback
|
||||
def update_data(self):
|
||||
"""Update data."""
|
||||
if self._coordinator.last_update_success:
|
||||
self._state = self._coordinator.data[f"s{self._slot}"]
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _async_send_packet(self, packet):
|
||||
"""Send packet to device."""
|
||||
"""Send a packet to the device."""
|
||||
try:
|
||||
await self.device.async_request(
|
||||
self.device.api.set_power, self._slot, packet
|
||||
await self._device.async_request(
|
||||
self._device.api.set_power, self._slot, packet
|
||||
)
|
||||
except BroadlinkException as err_msg:
|
||||
_LOGGER.error("Failed to send packet: %s", err_msg)
|
||||
except (BroadlinkException, OSError) as err:
|
||||
_LOGGER.error("Failed to send packet: %s", err)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class BroadlinkMP1Switch:
|
||||
"""Representation of a Broadlink switch - To fetch states of all slots."""
|
||||
|
||||
def __init__(self, device):
|
||||
"""Initialize the switch."""
|
||||
self.device = device
|
||||
self._states = None
|
||||
|
||||
def get_outlet_status(self, slot):
|
||||
"""Get status of outlet from cached status list."""
|
||||
if self._states is None:
|
||||
return None
|
||||
return self._states[f"s{slot}"]
|
||||
|
||||
@Throttle(TIME_BETWEEN_UPDATES)
|
||||
async def async_update(self):
|
||||
"""Update the state of the device."""
|
||||
try:
|
||||
states = await self.device.async_request(self.device.api.check_power)
|
||||
except BroadlinkException as err_msg:
|
||||
_LOGGER.error("Failed to update state: %s", err_msg)
|
||||
self._states = states
|
||||
|
|
46
homeassistant/components/broadlink/translations/en.json
Normal file
46
homeassistant/components/broadlink/translations/en.json
Normal file
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"config": {
|
||||
"flow_title": "{name} ({model} at {host})",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to the device",
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"timeout": "Timeout"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authenticate to the device"
|
||||
},
|
||||
"reset": {
|
||||
"title": "Unlock the device",
|
||||
"description": "Your device is locked for authentication. Follow the instructions to unlock it:\n1. Factory reset the device.\n2. Use the official app to add the device to your local network.\n3. Stop. Do not finish the setup. Close the app.\n4. Click Submit."
|
||||
},
|
||||
"unlock": {
|
||||
"title": "Unlock the device (optional)",
|
||||
"description": "Your device is locked. This can lead to authentication problems in Home Assistant. Would you like to unlock it?",
|
||||
"data": {
|
||||
"unlock": "Yes, do it."
|
||||
}
|
||||
},
|
||||
"finish": {
|
||||
"title": "Choose a name for the device",
|
||||
"data": {
|
||||
"name": "Name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured",
|
||||
"already_in_progress": "There is already a configuration flow in progress for this device",
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_host": "Invalid hostname or IP address",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_host": "Invalid hostname or IP address",
|
||||
"unknown": "Unexpected error"
|
||||
}
|
||||
}
|
||||
}
|
127
homeassistant/components/broadlink/updater.py
Normal file
127
homeassistant/components/broadlink/updater.py
Normal file
|
@ -0,0 +1,127 @@
|
|||
"""Support for fetching data from Broadlink devices."""
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from broadlink.exceptions import (
|
||||
AuthorizationError,
|
||||
BroadlinkException,
|
||||
CommandNotSupportedError,
|
||||
StorageError,
|
||||
)
|
||||
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_update_manager(device):
|
||||
"""Return an update manager for a given Broadlink device."""
|
||||
update_managers = {
|
||||
"A1": BroadlinkA1UpdateManager,
|
||||
"MP1": BroadlinkMP1UpdateManager,
|
||||
"RM2": BroadlinkRMUpdateManager,
|
||||
"RM4": BroadlinkRMUpdateManager,
|
||||
"SP1": BroadlinkSP1UpdateManager,
|
||||
"SP2": BroadlinkSP2UpdateManager,
|
||||
}
|
||||
return update_managers[device.api.type](device)
|
||||
|
||||
|
||||
class BroadlinkUpdateManager(ABC):
|
||||
"""Representation of a Broadlink update manager.
|
||||
|
||||
Implement this class to manage fetching data from the device and to
|
||||
monitor device availability.
|
||||
"""
|
||||
|
||||
def __init__(self, device):
|
||||
"""Initialize the update manager."""
|
||||
self.device = device
|
||||
self.coordinator = DataUpdateCoordinator(
|
||||
device.hass,
|
||||
_LOGGER,
|
||||
name="device",
|
||||
update_method=self.async_update,
|
||||
update_interval=timedelta(minutes=1),
|
||||
)
|
||||
self.available = None
|
||||
self.last_update = None
|
||||
|
||||
async def async_update(self):
|
||||
"""Fetch data from the device and update availability."""
|
||||
try:
|
||||
data = await self.async_fetch_data()
|
||||
|
||||
except (BroadlinkException, OSError) as err:
|
||||
if self.available and (
|
||||
dt.utcnow() - self.last_update > timedelta(minutes=3)
|
||||
or isinstance(err, (AuthorizationError, OSError))
|
||||
):
|
||||
self.available = False
|
||||
_LOGGER.warning(
|
||||
"Disconnected from the device at %s", self.device.api.host[0]
|
||||
)
|
||||
raise UpdateFailed(err)
|
||||
|
||||
else:
|
||||
if self.available is False:
|
||||
_LOGGER.warning(
|
||||
"Connected to the device at %s", self.device.api.host[0]
|
||||
)
|
||||
self.available = True
|
||||
self.last_update = dt.utcnow()
|
||||
return data
|
||||
|
||||
@abstractmethod
|
||||
async def async_fetch_data(self):
|
||||
"""Fetch data from the device."""
|
||||
|
||||
|
||||
class BroadlinkA1UpdateManager(BroadlinkUpdateManager):
|
||||
"""Manages updates for Broadlink A1 devices."""
|
||||
|
||||
async def async_fetch_data(self):
|
||||
"""Fetch data from the device."""
|
||||
return await self.device.async_request(self.device.api.check_sensors_raw)
|
||||
|
||||
|
||||
class BroadlinkMP1UpdateManager(BroadlinkUpdateManager):
|
||||
"""Manages updates for Broadlink MP1 devices."""
|
||||
|
||||
async def async_fetch_data(self):
|
||||
"""Fetch data from the device."""
|
||||
return await self.device.async_request(self.device.api.check_power)
|
||||
|
||||
|
||||
class BroadlinkRMUpdateManager(BroadlinkUpdateManager):
|
||||
"""Manages updates for Broadlink RM2 and RM4 devices."""
|
||||
|
||||
async def async_fetch_data(self):
|
||||
"""Fetch data from the device."""
|
||||
return await self.device.async_request(self.device.api.check_sensors)
|
||||
|
||||
|
||||
class BroadlinkSP1UpdateManager(BroadlinkUpdateManager):
|
||||
"""Manages updates for Broadlink SP1 devices."""
|
||||
|
||||
async def async_fetch_data(self):
|
||||
"""Fetch data from the device."""
|
||||
return None
|
||||
|
||||
|
||||
class BroadlinkSP2UpdateManager(BroadlinkUpdateManager):
|
||||
"""Manages updates for Broadlink SP2 devices."""
|
||||
|
||||
async def async_fetch_data(self):
|
||||
"""Fetch data from the device."""
|
||||
data = {}
|
||||
data["state"] = await self.device.async_request(self.device.api.check_power)
|
||||
try:
|
||||
data["load_power"] = await self.device.async_request(
|
||||
self.device.api.get_energy
|
||||
)
|
||||
except (CommandNotSupportedError, StorageError):
|
||||
data["load_power"] = None
|
||||
return data
|
|
@ -27,6 +27,7 @@ FLOWS = [
|
|||
"blink",
|
||||
"bond",
|
||||
"braviatv",
|
||||
"broadlink",
|
||||
"brother",
|
||||
"bsblan",
|
||||
"cast",
|
||||
|
|
|
@ -378,7 +378,7 @@ boto3==1.9.252
|
|||
bravia-tv==1.0.6
|
||||
|
||||
# homeassistant.components.broadlink
|
||||
broadlink==0.14.0
|
||||
broadlink==0.14.1
|
||||
|
||||
# homeassistant.components.brother
|
||||
brother==0.1.14
|
||||
|
|
|
@ -199,7 +199,7 @@ bond-api==0.1.8
|
|||
bravia-tv==1.0.6
|
||||
|
||||
# homeassistant.components.broadlink
|
||||
broadlink==0.14.0
|
||||
broadlink==0.14.1
|
||||
|
||||
# homeassistant.components.brother
|
||||
brother==0.1.14
|
||||
|
|
|
@ -1 +1,86 @@
|
|||
"""The tests for broadlink platforms."""
|
||||
"""Tests for the Broadlink integration."""
|
||||
from homeassistant.components.broadlink.const import DOMAIN
|
||||
|
||||
from tests.async_mock import MagicMock
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
# Do not edit/remove. Adding is ok.
|
||||
BROADLINK_DEVICES = {
|
||||
"Living Room": (
|
||||
"192.168.0.12",
|
||||
"34ea34b43b5a",
|
||||
"RM mini 3",
|
||||
"Broadlink",
|
||||
"RM4",
|
||||
0x5F36,
|
||||
44017,
|
||||
10,
|
||||
),
|
||||
"Office": (
|
||||
"192.168.0.13",
|
||||
"34ea34b43d22",
|
||||
"RM pro",
|
||||
"Broadlink",
|
||||
"RM2",
|
||||
0x2787,
|
||||
20025,
|
||||
7,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class BroadlinkDevice:
|
||||
"""Representation of a Broadlink device."""
|
||||
|
||||
def __init__(
|
||||
self, name, host, mac, model, manufacturer, type_, devtype, fwversion, timeout
|
||||
):
|
||||
"""Initialize the device."""
|
||||
self.name: str = name
|
||||
self.host: str = host
|
||||
self.mac: str = mac
|
||||
self.model: str = model
|
||||
self.manufacturer: str = manufacturer
|
||||
self.type: str = type_
|
||||
self.devtype: int = devtype
|
||||
self.timeout: int = timeout
|
||||
self.fwversion: int = fwversion
|
||||
|
||||
def get_mock_api(self):
|
||||
"""Return a mock device (API)."""
|
||||
mock_api = MagicMock()
|
||||
mock_api.name = self.name
|
||||
mock_api.host = (self.host, 80)
|
||||
mock_api.mac = bytes.fromhex(self.mac)
|
||||
mock_api.model = self.model
|
||||
mock_api.manufacturer = self.manufacturer
|
||||
mock_api.type = self.type
|
||||
mock_api.devtype = self.devtype
|
||||
mock_api.timeout = self.timeout
|
||||
mock_api.is_locked = False
|
||||
mock_api.auth.return_value = True
|
||||
mock_api.get_fwversion.return_value = self.fwversion
|
||||
return mock_api
|
||||
|
||||
def get_mock_entry(self):
|
||||
"""Return a mock config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=self.mac,
|
||||
title=self.name,
|
||||
data=self.get_entry_data(),
|
||||
)
|
||||
|
||||
def get_entry_data(self):
|
||||
"""Return entry data."""
|
||||
return {
|
||||
"host": self.host,
|
||||
"mac": self.mac,
|
||||
"type": self.devtype,
|
||||
"timeout": self.timeout,
|
||||
}
|
||||
|
||||
|
||||
def get_device(name):
|
||||
"""Get a device by name."""
|
||||
return BroadlinkDevice(name, *BROADLINK_DEVICES[name])
|
||||
|
|
748
tests/components/broadlink/test_config_flow.py
Normal file
748
tests/components/broadlink/test_config_flow.py
Normal file
|
@ -0,0 +1,748 @@
|
|||
"""Test the Broadlink config flow."""
|
||||
import errno
|
||||
import socket
|
||||
|
||||
import broadlink.exceptions as blke
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.broadlink.const import DOMAIN
|
||||
|
||||
from . import get_device
|
||||
|
||||
from tests.async_mock import call, patch
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def broadlink_setup_fixture():
|
||||
"""Mock broadlink entry setup."""
|
||||
with patch(
|
||||
"homeassistant.components.broadlink.async_setup_entry", return_value=True
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
async def test_flow_user_works(hass):
|
||||
"""Test a config flow initiated by the user.
|
||||
|
||||
Best case scenario with no errors or locks.
|
||||
"""
|
||||
device = get_device("Living Room")
|
||||
mock_api = device.get_mock_api()
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {}
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]) as mock_discover:
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host, "timeout": device.timeout},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "finish"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"name": device.name},
|
||||
)
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["title"] == device.name
|
||||
assert result["data"] == device.get_entry_data()
|
||||
|
||||
assert mock_discover.call_count == 1
|
||||
assert mock_api.auth.call_count == 1
|
||||
|
||||
|
||||
async def test_flow_user_already_in_progress(hass):
|
||||
"""Test we do not accept more than one config flow per device."""
|
||||
device = get_device("Living Room")
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", return_value=[device.get_mock_api()]):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host, "timeout": device.timeout},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", return_value=[device.get_mock_api()]):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host, "timeout": device.timeout},
|
||||
)
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "already_in_progress"
|
||||
|
||||
|
||||
async def test_flow_user_mac_already_configured(hass):
|
||||
"""Test we do not accept more than one config entry per device.
|
||||
|
||||
We need to abort the flow and update the existing entry.
|
||||
"""
|
||||
device = get_device("Living Room")
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
device.host = "192.168.1.64"
|
||||
device.timeout = 20
|
||||
mock_api = device.get_mock_api()
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host, "timeout": device.timeout},
|
||||
)
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
assert dict(mock_entry.data) == device.get_entry_data()
|
||||
assert mock_api.auth.call_count == 0
|
||||
|
||||
|
||||
async def test_flow_user_invalid_ip_address(hass):
|
||||
"""Test we handle an invalid IP address in the user step."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", side_effect=OSError(errno.EINVAL, None)):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": "0.0.0.1"},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "invalid_host"}
|
||||
|
||||
|
||||
async def test_flow_user_invalid_hostname(hass):
|
||||
"""Test we handle an invalid hostname in the user step."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", side_effect=OSError(socket.EAI_NONAME, None)):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": "pancakemaster.local"},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "invalid_host"}
|
||||
|
||||
|
||||
async def test_flow_user_device_not_found(hass):
|
||||
"""Test we handle a device not found in the user step."""
|
||||
device = get_device("Living Room")
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", return_value=[]):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_flow_user_network_unreachable(hass):
|
||||
"""Test we handle a network unreachable in the user step."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", side_effect=OSError(errno.ENETUNREACH, None)):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": "192.168.1.32"},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_flow_user_os_error(hass):
|
||||
"""Test we handle an OS error in the user step."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", side_effect=OSError()):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": "192.168.1.32"},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "unknown"}
|
||||
|
||||
|
||||
async def test_flow_auth_authentication_error(hass):
|
||||
"""Test we handle an authentication error in the auth step."""
|
||||
device = get_device("Living Room")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.auth.side_effect = blke.AuthenticationError()
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host, "timeout": device.timeout},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "reset"
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
|
||||
async def test_flow_auth_device_offline(hass):
|
||||
"""Test we handle a device offline in the auth step."""
|
||||
device = get_device("Living Room")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.auth.side_effect = blke.DeviceOfflineError()
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "auth"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_flow_auth_firmware_error(hass):
|
||||
"""Test we handle a firmware error in the auth step."""
|
||||
device = get_device("Living Room")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.auth.side_effect = blke.BroadlinkException()
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "auth"
|
||||
assert result["errors"] == {"base": "unknown"}
|
||||
|
||||
|
||||
async def test_flow_auth_network_unreachable(hass):
|
||||
"""Test we handle a network unreachable in the auth step."""
|
||||
device = get_device("Living Room")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.auth.side_effect = OSError(errno.ENETUNREACH, None)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "auth"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_flow_auth_os_error(hass):
|
||||
"""Test we handle an OS error in the auth step."""
|
||||
device = get_device("Living Room")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.auth.side_effect = OSError()
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "auth"
|
||||
assert result["errors"] == {"base": "unknown"}
|
||||
|
||||
|
||||
async def test_flow_reset_works(hass):
|
||||
"""Test we finish a config flow after a factory reset."""
|
||||
device = get_device("Living Room")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.auth.side_effect = blke.AuthenticationError()
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host, "timeout": device.timeout},
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", return_value=[device.get_mock_api()]):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host, "timeout": device.timeout},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"name": device.name},
|
||||
)
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["title"] == device.name
|
||||
assert result["data"] == device.get_entry_data()
|
||||
|
||||
|
||||
async def test_flow_unlock_works(hass):
|
||||
"""Test we finish a config flow with an unlock request."""
|
||||
device = get_device("Living Room")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.is_locked = True
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host, "timeout": device.timeout},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "unlock"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"unlock": True},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"name": device.name},
|
||||
)
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["title"] == device.name
|
||||
assert result["data"] == device.get_entry_data()
|
||||
|
||||
assert mock_api.set_lock.call_args == call(False)
|
||||
assert mock_api.set_lock.call_count == 1
|
||||
|
||||
|
||||
async def test_flow_unlock_device_offline(hass):
|
||||
"""Test we handle a device offline in the unlock step."""
|
||||
device = get_device("Living Room")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.is_locked = True
|
||||
mock_api.set_lock.side_effect = blke.DeviceOfflineError
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host, "timeout": device.timeout},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"unlock": True},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "unlock"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_flow_unlock_firmware_error(hass):
|
||||
"""Test we handle a firmware error in the unlock step."""
|
||||
device = get_device("Living Room")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.is_locked = True
|
||||
mock_api.set_lock.side_effect = blke.BroadlinkException
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host, "timeout": device.timeout},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"unlock": True},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "unlock"
|
||||
assert result["errors"] == {"base": "unknown"}
|
||||
|
||||
|
||||
async def test_flow_unlock_network_unreachable(hass):
|
||||
"""Test we handle a network unreachable in the unlock step."""
|
||||
device = get_device("Living Room")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.is_locked = True
|
||||
mock_api.set_lock.side_effect = OSError(errno.ENETUNREACH, None)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host, "timeout": device.timeout},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"unlock": True},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "unlock"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_flow_unlock_os_error(hass):
|
||||
"""Test we handle an OS error in the unlock step."""
|
||||
device = get_device("Living Room")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.is_locked = True
|
||||
mock_api.set_lock.side_effect = OSError()
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host, "timeout": device.timeout},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"unlock": True},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "unlock"
|
||||
assert result["errors"] == {"base": "unknown"}
|
||||
|
||||
|
||||
async def test_flow_do_not_unlock(hass):
|
||||
"""Test we do not unlock the device if the user does not want to."""
|
||||
device = get_device("Living Room")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.is_locked = True
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host, "timeout": device.timeout},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"unlock": False},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"name": device.name},
|
||||
)
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["title"] == device.name
|
||||
assert result["data"] == device.get_entry_data()
|
||||
|
||||
assert mock_api.set_lock.call_count == 0
|
||||
|
||||
|
||||
async def test_flow_import_works(hass):
|
||||
"""Test an import flow."""
|
||||
device = get_device("Living Room")
|
||||
mock_api = device.get_mock_api()
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]) as mock_discover:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data={"host": device.host},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "finish"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"name": device.name},
|
||||
)
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["title"] == device.name
|
||||
assert result["data"]["host"] == device.host
|
||||
assert result["data"]["mac"] == device.mac
|
||||
assert result["data"]["type"] == device.devtype
|
||||
|
||||
assert mock_api.auth.call_count == 1
|
||||
assert mock_discover.call_count == 1
|
||||
|
||||
|
||||
async def test_flow_import_already_in_progress(hass):
|
||||
"""Test we do not import more than one flow per device."""
|
||||
device = get_device("Living Room")
|
||||
data = {"host": device.host}
|
||||
|
||||
with patch("broadlink.discover", return_value=[device.get_mock_api()]):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=data
|
||||
)
|
||||
|
||||
with patch("broadlink.discover", return_value=[device.get_mock_api()]):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=data
|
||||
)
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "already_in_progress"
|
||||
|
||||
|
||||
async def test_flow_import_host_already_configured(hass):
|
||||
"""Test we do not import a host that is already configured."""
|
||||
device = get_device("Living Room")
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
mock_api = device.get_mock_api()
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data={"host": device.host},
|
||||
)
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_flow_import_mac_already_configured(hass):
|
||||
"""Test we do not import more than one config entry per device.
|
||||
|
||||
We need to abort the flow and update the existing entry.
|
||||
"""
|
||||
device = get_device("Living Room")
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
device.host = "192.168.1.16"
|
||||
mock_api = device.get_mock_api()
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data={"host": device.host},
|
||||
)
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
assert mock_entry.data["host"] == device.host
|
||||
assert mock_entry.data["mac"] == device.mac
|
||||
assert mock_entry.data["type"] == device.devtype
|
||||
assert mock_api.auth.call_count == 0
|
||||
|
||||
|
||||
async def test_flow_import_device_not_found(hass):
|
||||
"""Test we handle a device not found in the import step."""
|
||||
with patch("broadlink.discover", return_value=[]):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data={"host": "192.168.1.32"},
|
||||
)
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "cannot_connect"
|
||||
|
||||
|
||||
async def test_flow_import_invalid_ip_address(hass):
|
||||
"""Test we handle an invalid IP address in the import step."""
|
||||
with patch("broadlink.discover", side_effect=OSError(errno.EINVAL, None)):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data={"host": "0.0.0.1"},
|
||||
)
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "invalid_host"
|
||||
|
||||
|
||||
async def test_flow_import_invalid_hostname(hass):
|
||||
"""Test we handle an invalid hostname in the import step."""
|
||||
with patch("broadlink.discover", side_effect=OSError(socket.EAI_NONAME, None)):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data={"host": "hotdog.local"},
|
||||
)
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "invalid_host"
|
||||
|
||||
|
||||
async def test_flow_import_network_unreachable(hass):
|
||||
"""Test we handle a network unreachable in the import step."""
|
||||
with patch("broadlink.discover", side_effect=OSError(errno.ENETUNREACH, None)):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data={"host": "192.168.1.64"},
|
||||
)
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "cannot_connect"
|
||||
|
||||
|
||||
async def test_flow_import_os_error(hass):
|
||||
"""Test we handle an OS error in the import step."""
|
||||
with patch("broadlink.discover", side_effect=OSError()):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data={"host": "192.168.1.64"},
|
||||
)
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "unknown"
|
||||
|
||||
|
||||
async def test_flow_reauth_works(hass):
|
||||
"""Test a reauthentication flow."""
|
||||
device = get_device("Living Room")
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.auth.side_effect = blke.AuthenticationError()
|
||||
data = {"name": device.name, **device.get_entry_data()}
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": "reauth"}, data=data
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "reset"
|
||||
|
||||
mock_api = device.get_mock_api()
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]) as mock_discover:
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host, "timeout": device.timeout},
|
||||
)
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
assert dict(mock_entry.data) == device.get_entry_data()
|
||||
assert mock_api.auth.call_count == 1
|
||||
assert mock_discover.call_count == 1
|
||||
|
||||
|
||||
async def test_flow_reauth_invalid_host(hass):
|
||||
"""Test we do not accept an invalid host for reauthentication.
|
||||
|
||||
The MAC address cannot change.
|
||||
"""
|
||||
device = get_device("Living Room")
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.auth.side_effect = blke.AuthenticationError()
|
||||
data = {"name": device.name, **device.get_entry_data()}
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": "reauth"}, data=data
|
||||
)
|
||||
|
||||
device.mac = get_device("Office").mac
|
||||
mock_api = device.get_mock_api()
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]) as mock_discover:
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host, "timeout": device.timeout},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "invalid_host"}
|
||||
|
||||
assert mock_discover.call_count == 1
|
||||
assert mock_api.auth.call_count == 0
|
||||
|
||||
|
||||
async def test_flow_reauth_valid_host(hass):
|
||||
"""Test we accept a valid host for reauthentication.
|
||||
|
||||
The hostname/IP address may change. We need to update the entry.
|
||||
"""
|
||||
device = get_device("Living Room")
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.auth.side_effect = blke.AuthenticationError()
|
||||
data = {"name": device.name, **device.get_entry_data()}
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": "reauth"}, data=data
|
||||
)
|
||||
|
||||
device.host = "192.168.1.128"
|
||||
mock_api = device.get_mock_api()
|
||||
|
||||
with patch("broadlink.discover", return_value=[mock_api]) as mock_discover:
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": device.host, "timeout": device.timeout},
|
||||
)
|
||||
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
assert mock_entry.data["host"] == device.host
|
||||
assert mock_discover.call_count == 1
|
||||
assert mock_api.auth.call_count == 1
|
389
tests/components/broadlink/test_device.py
Normal file
389
tests/components/broadlink/test_device.py
Normal file
|
@ -0,0 +1,389 @@
|
|||
"""Tests for Broadlink devices."""
|
||||
import broadlink.exceptions as blke
|
||||
|
||||
from homeassistant.components.broadlink.const import DOMAIN
|
||||
from homeassistant.components.broadlink.device import get_domains
|
||||
from homeassistant.config_entries import (
|
||||
ENTRY_STATE_LOADED,
|
||||
ENTRY_STATE_NOT_LOADED,
|
||||
ENTRY_STATE_SETUP_ERROR,
|
||||
ENTRY_STATE_SETUP_RETRY,
|
||||
)
|
||||
from homeassistant.helpers.entity_registry import async_entries_for_device
|
||||
|
||||
from . import get_device
|
||||
|
||||
from tests.async_mock import patch
|
||||
from tests.common import mock_device_registry, mock_registry
|
||||
|
||||
|
||||
async def test_device_setup(hass):
|
||||
"""Test a successful setup."""
|
||||
device = get_device("Office")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api), patch.object(
|
||||
hass.config_entries, "async_forward_entry_setup"
|
||||
) as mock_forward, patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
) as mock_init:
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
|
||||
assert mock_entry.state == ENTRY_STATE_LOADED
|
||||
assert mock_api.auth.call_count == 1
|
||||
assert mock_api.get_fwversion.call_count == 1
|
||||
forward_entries = {c[1][1] for c in mock_forward.mock_calls}
|
||||
domains = get_domains(mock_api.type)
|
||||
assert mock_forward.call_count == len(domains)
|
||||
assert forward_entries == domains
|
||||
assert mock_init.call_count == 0
|
||||
|
||||
|
||||
async def test_device_setup_authentication_error(hass):
|
||||
"""Test we handle an authentication error."""
|
||||
device = get_device("Living Room")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.auth.side_effect = blke.AuthenticationError()
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api), patch.object(
|
||||
hass.config_entries, "async_forward_entry_setup"
|
||||
) as mock_forward, patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
) as mock_init:
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
|
||||
assert mock_entry.state == ENTRY_STATE_SETUP_ERROR
|
||||
assert mock_api.auth.call_count == 1
|
||||
assert mock_forward.call_count == 0
|
||||
assert mock_init.call_count == 1
|
||||
assert mock_init.mock_calls[0][2]["context"]["source"] == "reauth"
|
||||
assert mock_init.mock_calls[0][2]["data"] == {
|
||||
"name": device.name,
|
||||
**device.get_entry_data(),
|
||||
}
|
||||
|
||||
|
||||
async def test_device_setup_device_offline(hass):
|
||||
"""Test we handle a device offline."""
|
||||
device = get_device("Office")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.auth.side_effect = blke.DeviceOfflineError()
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api), patch.object(
|
||||
hass.config_entries, "async_forward_entry_setup"
|
||||
) as mock_forward, patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
) as mock_init:
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
|
||||
assert mock_entry.state == ENTRY_STATE_SETUP_RETRY
|
||||
assert mock_api.auth.call_count == 1
|
||||
assert mock_forward.call_count == 0
|
||||
assert mock_init.call_count == 0
|
||||
|
||||
|
||||
async def test_device_setup_os_error(hass):
|
||||
"""Test we handle an OS error."""
|
||||
device = get_device("Office")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.auth.side_effect = OSError()
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api), patch.object(
|
||||
hass.config_entries, "async_forward_entry_setup"
|
||||
) as mock_forward, patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
) as mock_init:
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
|
||||
assert mock_entry.state == ENTRY_STATE_SETUP_RETRY
|
||||
assert mock_api.auth.call_count == 1
|
||||
assert mock_forward.call_count == 0
|
||||
assert mock_init.call_count == 0
|
||||
|
||||
|
||||
async def test_device_setup_broadlink_exception(hass):
|
||||
"""Test we handle a Broadlink exception."""
|
||||
device = get_device("Office")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.auth.side_effect = blke.BroadlinkException()
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api), patch.object(
|
||||
hass.config_entries, "async_forward_entry_setup"
|
||||
) as mock_forward, patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
) as mock_init:
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
|
||||
assert mock_entry.state == ENTRY_STATE_SETUP_ERROR
|
||||
assert mock_api.auth.call_count == 1
|
||||
assert mock_forward.call_count == 0
|
||||
assert mock_init.call_count == 0
|
||||
|
||||
|
||||
async def test_device_setup_update_device_offline(hass):
|
||||
"""Test we handle a device offline in the update step."""
|
||||
device = get_device("Office")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.check_sensors.side_effect = blke.DeviceOfflineError()
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api), patch.object(
|
||||
hass.config_entries, "async_forward_entry_setup"
|
||||
) as mock_forward, patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
) as mock_init:
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
|
||||
assert mock_entry.state == ENTRY_STATE_SETUP_RETRY
|
||||
assert mock_api.auth.call_count == 1
|
||||
assert mock_api.check_sensors.call_count == 1
|
||||
assert mock_forward.call_count == 0
|
||||
assert mock_init.call_count == 0
|
||||
|
||||
|
||||
async def test_device_setup_update_authorization_error(hass):
|
||||
"""Test we handle an authorization error in the update step."""
|
||||
device = get_device("Office")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.check_sensors.side_effect = (blke.AuthorizationError(), None)
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api), patch.object(
|
||||
hass.config_entries, "async_forward_entry_setup"
|
||||
) as mock_forward, patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
) as mock_init:
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
|
||||
assert mock_entry.state == ENTRY_STATE_LOADED
|
||||
assert mock_api.auth.call_count == 2
|
||||
assert mock_api.check_sensors.call_count == 2
|
||||
forward_entries = {c[1][1] for c in mock_forward.mock_calls}
|
||||
domains = get_domains(mock_api.type)
|
||||
assert mock_forward.call_count == len(domains)
|
||||
assert forward_entries == domains
|
||||
assert mock_init.call_count == 0
|
||||
|
||||
|
||||
async def test_device_setup_update_authentication_error(hass):
|
||||
"""Test we handle an authentication error in the update step."""
|
||||
device = get_device("Living Room")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.check_sensors.side_effect = blke.AuthorizationError()
|
||||
mock_api.auth.side_effect = (None, blke.AuthenticationError())
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api), patch.object(
|
||||
hass.config_entries, "async_forward_entry_setup"
|
||||
) as mock_forward, patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
) as mock_init:
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
|
||||
assert mock_entry.state == ENTRY_STATE_SETUP_RETRY
|
||||
assert mock_api.auth.call_count == 2
|
||||
assert mock_api.check_sensors.call_count == 1
|
||||
assert mock_forward.call_count == 0
|
||||
assert mock_init.call_count == 1
|
||||
assert mock_init.mock_calls[0][2]["context"]["source"] == "reauth"
|
||||
assert mock_init.mock_calls[0][2]["data"] == {
|
||||
"name": device.name,
|
||||
**device.get_entry_data(),
|
||||
}
|
||||
|
||||
|
||||
async def test_device_setup_update_broadlink_exception(hass):
|
||||
"""Test we handle a Broadlink exception in the update step."""
|
||||
device = get_device("Living Room")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.check_sensors.side_effect = blke.BroadlinkException()
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api), patch.object(
|
||||
hass.config_entries, "async_forward_entry_setup"
|
||||
) as mock_forward, patch.object(
|
||||
hass.config_entries.flow, "async_init"
|
||||
) as mock_init:
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
|
||||
assert mock_entry.state == ENTRY_STATE_SETUP_RETRY
|
||||
assert mock_api.auth.call_count == 1
|
||||
assert mock_api.check_sensors.call_count == 1
|
||||
assert mock_forward.call_count == 0
|
||||
assert mock_init.call_count == 0
|
||||
|
||||
|
||||
async def test_device_setup_get_fwversion_broadlink_exception(hass):
|
||||
"""Test we load the device even if we cannot read the firmware version."""
|
||||
device = get_device("Office")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.get_fwversion.side_effect = blke.BroadlinkException()
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api), patch.object(
|
||||
hass.config_entries, "async_forward_entry_setup"
|
||||
) as mock_forward:
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
|
||||
assert mock_entry.state == ENTRY_STATE_LOADED
|
||||
forward_entries = {c[1][1] for c in mock_forward.mock_calls}
|
||||
domains = get_domains(mock_api.type)
|
||||
assert mock_forward.call_count == len(domains)
|
||||
assert forward_entries == domains
|
||||
|
||||
|
||||
async def test_device_setup_get_fwversion_os_error(hass):
|
||||
"""Test we load the device even if we cannot read the firmware version."""
|
||||
device = get_device("Office")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.get_fwversion.side_effect = OSError()
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api), patch.object(
|
||||
hass.config_entries, "async_forward_entry_setup"
|
||||
) as mock_forward:
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
|
||||
assert mock_entry.state == ENTRY_STATE_LOADED
|
||||
forward_entries = {c[1][1] for c in mock_forward.mock_calls}
|
||||
domains = get_domains(mock_api.type)
|
||||
assert mock_forward.call_count == len(domains)
|
||||
assert forward_entries == domains
|
||||
|
||||
|
||||
async def test_device_setup_registry(hass):
|
||||
"""Test we register the device and the entries correctly."""
|
||||
device = get_device("Office")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
device_registry = mock_device_registry(hass)
|
||||
entity_registry = mock_registry(hass)
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api):
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(device_registry.devices) == 1
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
{(DOMAIN, mock_entry.unique_id)}, set()
|
||||
)
|
||||
assert device_entry.identifiers == {(DOMAIN, device.mac)}
|
||||
assert device_entry.name == device.name
|
||||
assert device_entry.model == device.model
|
||||
assert device_entry.manufacturer == device.manufacturer
|
||||
assert device_entry.sw_version == device.fwversion
|
||||
|
||||
for entry in async_entries_for_device(entity_registry, device_entry.id):
|
||||
assert entry.original_name.startswith(device.name)
|
||||
|
||||
|
||||
async def test_device_unload_works(hass):
|
||||
"""Test we unload the device."""
|
||||
device = get_device("Office")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api), patch.object(
|
||||
hass.config_entries, "async_forward_entry_setup"
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
|
||||
with patch.object(
|
||||
hass.config_entries, "async_forward_entry_unload", return_value=True
|
||||
) as mock_forward:
|
||||
await hass.config_entries.async_unload(mock_entry.entry_id)
|
||||
|
||||
assert mock_entry.state == ENTRY_STATE_NOT_LOADED
|
||||
forward_entries = {c[1][1] for c in mock_forward.mock_calls}
|
||||
domains = get_domains(mock_api.type)
|
||||
assert mock_forward.call_count == len(domains)
|
||||
assert forward_entries == domains
|
||||
|
||||
|
||||
async def test_device_unload_authentication_error(hass):
|
||||
"""Test we unload a device that failed the authentication step."""
|
||||
device = get_device("Living Room")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.auth.side_effect = blke.AuthenticationError()
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api), patch.object(
|
||||
hass.config_entries, "async_forward_entry_setup"
|
||||
), patch.object(hass.config_entries.flow, "async_init"):
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
|
||||
with patch.object(
|
||||
hass.config_entries, "async_forward_entry_unload", return_value=True
|
||||
) as mock_forward:
|
||||
await hass.config_entries.async_unload(mock_entry.entry_id)
|
||||
|
||||
assert mock_entry.state == ENTRY_STATE_NOT_LOADED
|
||||
assert mock_forward.call_count == 0
|
||||
|
||||
|
||||
async def test_device_unload_update_failed(hass):
|
||||
"""Test we unload a device that failed the update step."""
|
||||
device = get_device("Office")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_api.check_sensors.side_effect = blke.DeviceOfflineError()
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api), patch.object(
|
||||
hass.config_entries, "async_forward_entry_setup"
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
|
||||
with patch.object(
|
||||
hass.config_entries, "async_forward_entry_unload", return_value=True
|
||||
) as mock_forward:
|
||||
await hass.config_entries.async_unload(mock_entry.entry_id)
|
||||
|
||||
assert mock_entry.state == ENTRY_STATE_NOT_LOADED
|
||||
assert mock_forward.call_count == 0
|
||||
|
||||
|
||||
async def test_device_update_listener(hass):
|
||||
"""Test we update device and entity registry when the entry is renamed."""
|
||||
device = get_device("Office")
|
||||
mock_api = device.get_mock_api()
|
||||
mock_entry = device.get_mock_entry()
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
device_registry = mock_device_registry(hass)
|
||||
entity_registry = mock_registry(hass)
|
||||
|
||||
with patch("broadlink.gendevice", return_value=mock_api):
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
hass.config_entries.async_update_entry(mock_entry, title="New Name")
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
{(DOMAIN, mock_entry.unique_id)}, set()
|
||||
)
|
||||
assert device_entry.name == "New Name"
|
||||
for entry in async_entries_for_device(entity_registry, device_entry.id):
|
||||
assert entry.original_name.startswith("New Name")
|
54
tests/components/broadlink/test_helpers.py
Normal file
54
tests/components/broadlink/test_helpers.py
Normal file
|
@ -0,0 +1,54 @@
|
|||
"""Tests for Broadlink helper functions."""
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.broadlink.helpers import data_packet, mac_address
|
||||
|
||||
|
||||
async def test_padding(hass):
|
||||
"""Verify that non padding strings are allowed."""
|
||||
assert data_packet("Jg") == b"&"
|
||||
assert data_packet("Jg=") == b"&"
|
||||
assert data_packet("Jg==") == b"&"
|
||||
|
||||
|
||||
async def test_valid_mac_address(hass):
|
||||
"""Test we convert a valid MAC address to bytes."""
|
||||
valid = [
|
||||
"A1B2C3D4E5F6",
|
||||
"a1b2c3d4e5f6",
|
||||
"A1B2-C3D4-E5F6",
|
||||
"a1b2-c3d4-e5f6",
|
||||
"A1B2.C3D4.E5F6",
|
||||
"a1b2.c3d4.e5f6",
|
||||
"A1-B2-C3-D4-E5-F6",
|
||||
"a1-b2-c3-d4-e5-f6",
|
||||
"A1:B2:C3:D4:E5:F6",
|
||||
"a1:b2:c3:d4:e5:f6",
|
||||
]
|
||||
for mac in valid:
|
||||
assert mac_address(mac) == b"\xa1\xb2\xc3\xd4\xe5\xf6"
|
||||
|
||||
|
||||
async def test_invalid_mac_address(hass):
|
||||
"""Test we do not accept an invalid MAC address."""
|
||||
invalid = [
|
||||
None,
|
||||
123,
|
||||
["a", "b", "c"],
|
||||
{"abc": "def"},
|
||||
"a1b2c3d4e5f",
|
||||
"a1b2.c3d4.e5f",
|
||||
"a1-b2-c3-d4-e5-f",
|
||||
"a1b2c3d4e5f66",
|
||||
"a1b2.c3d4.e5f66",
|
||||
"a1-b2-c3-d4-e5-f66",
|
||||
"a1b2c3d4e5fg",
|
||||
"a1b2.c3d4.e5fg",
|
||||
"a1-b2-c3-d4-e5-fg",
|
||||
"a1b.2c3d4.e5fg",
|
||||
"a1b-2-c3-d4-e5-fg",
|
||||
]
|
||||
for mac in invalid:
|
||||
with pytest.raises((ValueError, vol.Invalid)):
|
||||
mac_address(mac)
|
|
@ -1,102 +0,0 @@
|
|||
"""The tests for the broadlink component."""
|
||||
from base64 import b64decode
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.broadlink import async_setup_service, data_packet
|
||||
from homeassistant.components.broadlink.const import DOMAIN, SERVICE_LEARN, SERVICE_SEND
|
||||
from homeassistant.components.broadlink.device import BroadlinkDevice
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from tests.async_mock import MagicMock, call, patch
|
||||
|
||||
DUMMY_IR_PACKET = (
|
||||
"JgBGAJKVETkRORA6ERQRFBEUERQRFBE5ETkQOhAVEBUQFREUEBUQ"
|
||||
"OhEUERQRORE5EBURFBA6EBUQOhE5EBUQFRA6EDoRFBEADQUAAA=="
|
||||
)
|
||||
DUMMY_HOST = "192.168.0.2"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def dummy_broadlink():
|
||||
"""Mock broadlink module so we don't have that dependency on tests."""
|
||||
broadlink = MagicMock()
|
||||
with patch.dict("sys.modules", {"broadlink": broadlink}):
|
||||
yield broadlink
|
||||
|
||||
|
||||
async def test_padding(hass):
|
||||
"""Verify that non padding strings are allowed."""
|
||||
assert data_packet("Jg") == b"&"
|
||||
assert data_packet("Jg=") == b"&"
|
||||
assert data_packet("Jg==") == b"&"
|
||||
|
||||
|
||||
async def test_send(hass):
|
||||
"""Test send service."""
|
||||
mock_api = MagicMock()
|
||||
mock_api.send_data.return_value = None
|
||||
device = BroadlinkDevice(hass, mock_api)
|
||||
|
||||
await async_setup_service(hass, DUMMY_HOST, device)
|
||||
await hass.services.async_call(
|
||||
DOMAIN, SERVICE_SEND, {"host": DUMMY_HOST, "packet": (DUMMY_IR_PACKET)}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert device.api.send_data.call_count == 1
|
||||
assert device.api.send_data.call_args == call(b64decode(DUMMY_IR_PACKET))
|
||||
|
||||
|
||||
async def test_learn(hass):
|
||||
"""Test learn service."""
|
||||
mock_api = MagicMock()
|
||||
mock_api.enter_learning.return_value = None
|
||||
mock_api.check_data.return_value = b64decode(DUMMY_IR_PACKET)
|
||||
device = BroadlinkDevice(hass, mock_api)
|
||||
|
||||
with patch.object(
|
||||
hass.components.persistent_notification, "async_create"
|
||||
) as mock_create:
|
||||
|
||||
await async_setup_service(hass, DUMMY_HOST, device)
|
||||
await hass.services.async_call(DOMAIN, SERVICE_LEARN, {"host": DUMMY_HOST})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert device.api.enter_learning.call_count == 1
|
||||
assert device.api.enter_learning.call_args == call()
|
||||
|
||||
assert mock_create.call_count == 1
|
||||
assert mock_create.call_args == call(
|
||||
f"Received packet is: {DUMMY_IR_PACKET}", title="Broadlink switch"
|
||||
)
|
||||
|
||||
|
||||
async def test_learn_timeout(hass):
|
||||
"""Test learn service."""
|
||||
mock_api = MagicMock()
|
||||
mock_api.enter_learning.return_value = None
|
||||
mock_api.check_data.return_value = None
|
||||
device = BroadlinkDevice(hass, mock_api)
|
||||
|
||||
await async_setup_service(hass, DUMMY_HOST, device)
|
||||
|
||||
now = utcnow()
|
||||
|
||||
with patch.object(
|
||||
hass.components.persistent_notification, "async_create"
|
||||
) as mock_create, patch("homeassistant.components.broadlink.utcnow") as mock_utcnow:
|
||||
|
||||
mock_utcnow.side_effect = [now, now + timedelta(20)]
|
||||
|
||||
await hass.services.async_call(DOMAIN, SERVICE_LEARN, {"host": DUMMY_HOST})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert device.api.enter_learning.call_count == 1
|
||||
assert device.api.enter_learning.call_args == call()
|
||||
|
||||
assert mock_create.call_count == 1
|
||||
assert mock_create.call_args == call(
|
||||
"No signal was received", title="Broadlink switch"
|
||||
)
|
Loading…
Add table
Reference in a new issue