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:
Felipe Martins Diel 2020-08-20 12:30:41 -03:00 committed by GitHub
parent eb4f667a1a
commit a2c1f08c8c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 2497 additions and 795 deletions

View file

@ -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

View file

@ -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()

View 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()

View file

@ -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

View file

@ -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},
)
)

View 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)

View file

@ -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
}

View file

@ -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"

View file

@ -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()

View 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%]"
}
}
}

View file

@ -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

View 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"
}
}
}

View 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

View file

@ -27,6 +27,7 @@ FLOWS = [
"blink",
"bond",
"braviatv",
"broadlink",
"brother",
"bsblan",
"cast",

View file

@ -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

View file

@ -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

View file

@ -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])

View 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

View 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")

View 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)

View file

@ -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"
)