From a2c1f08c8ca01bed2dbdc0080fbe0287150d4c51 Mon Sep 17 00:00:00 2001 From: Felipe Martins Diel <41558831+felipediel@users.noreply.github.com> Date: Thu, 20 Aug 2020 12:30:41 -0300 Subject: [PATCH] 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 --- .coveragerc | 3 +- .../components/broadlink/__init__.py | 137 +--- .../components/broadlink/config_flow.py | 270 +++++++ homeassistant/components/broadlink/const.py | 48 +- homeassistant/components/broadlink/device.py | 181 ++++- homeassistant/components/broadlink/helpers.py | 49 ++ .../components/broadlink/manifest.json | 5 +- homeassistant/components/broadlink/remote.py | 309 ++++---- homeassistant/components/broadlink/sensor.py | 207 ++--- .../components/broadlink/strings.json | 46 ++ homeassistant/components/broadlink/switch.py | 479 ++++++----- .../components/broadlink/translations/en.json | 46 ++ homeassistant/components/broadlink/updater.py | 127 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/broadlink/__init__.py | 87 +- .../components/broadlink/test_config_flow.py | 748 ++++++++++++++++++ tests/components/broadlink/test_device.py | 389 +++++++++ tests/components/broadlink/test_helpers.py | 54 ++ tests/components/broadlink/test_init.py | 102 --- 21 files changed, 2497 insertions(+), 795 deletions(-) create mode 100644 homeassistant/components/broadlink/config_flow.py create mode 100644 homeassistant/components/broadlink/helpers.py create mode 100644 homeassistant/components/broadlink/strings.json create mode 100644 homeassistant/components/broadlink/translations/en.json create mode 100644 homeassistant/components/broadlink/updater.py create mode 100644 tests/components/broadlink/test_config_flow.py create mode 100644 tests/components/broadlink/test_device.py create mode 100644 tests/components/broadlink/test_helpers.py delete mode 100644 tests/components/broadlink/test_init.py diff --git a/.coveragerc b/.coveragerc index 8ca809626a0..dfac1a374b8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/homeassistant/components/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py index fe0c79a0b19..74f26b5fbd6 100644 --- a/homeassistant/components/broadlink/__init__.py +++ b/homeassistant/components/broadlink/__init__.py @@ -1,125 +1,34 @@ -"""The broadlink component.""" -import asyncio -from base64 import b64decode, b64encode -from binascii import unhexlify +"""The Broadlink integration.""" +from dataclasses import dataclass, field import logging -import re -from broadlink.exceptions import BroadlinkException, ReadError, StorageError -import voluptuous as vol +from .const import DOMAIN +from .device import BroadlinkDevice -from homeassistant.const import CONF_HOST -import homeassistant.helpers.config_validation as cv -from homeassistant.util.dt import utcnow - -from .const import CONF_PACKET, DOMAIN, LEARNING_TIMEOUT, SERVICE_LEARN, SERVICE_SEND - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_RETRY = 3 +LOGGER = logging.getLogger(__name__) -def data_packet(value): - """Decode a data packet given for broadlink.""" - value = cv.string(value) - extra = len(value) % 4 - if extra > 0: - value = value + ("=" * (4 - extra)) - return b64decode(value) +@dataclass +class BroadlinkData: + """Class for sharing data within the Broadlink integration.""" + + devices: dict = field(default_factory=dict) + platforms: dict = field(default_factory=dict) -def hostname(value): - """Validate a hostname.""" - host = str(value) - if len(host) > 253: - raise ValueError - if host[-1] == ".": - host = host[:-1] - allowed = re.compile(r"(?![_-])[a-z\d_-]{1,63}(? 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) diff --git a/homeassistant/components/broadlink/manifest.json b/homeassistant/components/broadlink/manifest.json index 76443ae7467..6a630044904 100644 --- a/homeassistant/components/broadlink/manifest.json +++ b/homeassistant/components/broadlink/manifest.json @@ -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 } diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index d7b4c051bcc..10c45fec262 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -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" diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index da2bc534859..def52a1bc29 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -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() diff --git a/homeassistant/components/broadlink/strings.json b/homeassistant/components/broadlink/strings.json new file mode 100644 index 00000000000..44cb1801ede --- /dev/null +++ b/homeassistant/components/broadlink/strings.json @@ -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%]" + } + } +} diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index d41bac3beae..4067d1e535d 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -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 diff --git a/homeassistant/components/broadlink/translations/en.json b/homeassistant/components/broadlink/translations/en.json new file mode 100644 index 00000000000..3784ef7eb89 --- /dev/null +++ b/homeassistant/components/broadlink/translations/en.json @@ -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" + } + } +} diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py new file mode 100644 index 00000000000..a81cde49737 --- /dev/null +++ b/homeassistant/components/broadlink/updater.py @@ -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 diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d315f9b73ca..d8934bdead8 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -27,6 +27,7 @@ FLOWS = [ "blink", "bond", "braviatv", + "broadlink", "brother", "bsblan", "cast", diff --git a/requirements_all.txt b/requirements_all.txt index e52d90c6633..f5f11184efe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6d63f155433..b19d66e7f53 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/broadlink/__init__.py b/tests/components/broadlink/__init__.py index c2d16b9ab2a..622fe4fba40 100644 --- a/tests/components/broadlink/__init__.py +++ b/tests/components/broadlink/__init__.py @@ -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]) diff --git a/tests/components/broadlink/test_config_flow.py b/tests/components/broadlink/test_config_flow.py new file mode 100644 index 00000000000..43237cd70ba --- /dev/null +++ b/tests/components/broadlink/test_config_flow.py @@ -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 diff --git a/tests/components/broadlink/test_device.py b/tests/components/broadlink/test_device.py new file mode 100644 index 00000000000..5cd0457b552 --- /dev/null +++ b/tests/components/broadlink/test_device.py @@ -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") diff --git a/tests/components/broadlink/test_helpers.py b/tests/components/broadlink/test_helpers.py new file mode 100644 index 00000000000..0983322d761 --- /dev/null +++ b/tests/components/broadlink/test_helpers.py @@ -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) diff --git a/tests/components/broadlink/test_init.py b/tests/components/broadlink/test_init.py deleted file mode 100644 index 5a359896bfa..00000000000 --- a/tests/components/broadlink/test_init.py +++ /dev/null @@ -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" - )