diff --git a/CODEOWNERS b/CODEOWNERS index 31db27145e4..ccb0cac17ea 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -147,7 +147,7 @@ homeassistant/components/griddy/* @bdraco homeassistant/components/group/* @home-assistant/core homeassistant/components/growatt_server/* @indykoning homeassistant/components/gtfs/* @robbiet480 -homeassistant/components/harmony/* @ehendrix23 +homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco homeassistant/components/hassio/* @home-assistant/hass-io homeassistant/components/heatmiser/* @andylockran homeassistant/components/heos/* @andrewsayre diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index d12e9d2c54b..64816acaaf3 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -75,7 +75,6 @@ SERVICE_HANDLERS = { "denonavr": ("media_player", "denonavr"), "frontier_silicon": ("media_player", "frontier_silicon"), "openhome": ("media_player", "openhome"), - "harmony": ("remote", "harmony"), "bose_soundtouch": ("media_player", "soundtouch"), "bluesound": ("media_player", "bluesound"), "songpal": ("media_player", "songpal"), @@ -93,6 +92,7 @@ MIGRATED_SERVICE_HANDLERS = [ "esphome", "google_cast", SERVICE_HEOS, + "harmony", "homekit", "ikea_tradfri", "philips_hue", diff --git a/homeassistant/components/harmony/.translations/en.json b/homeassistant/components/harmony/.translations/en.json new file mode 100644 index 00000000000..b183e067101 --- /dev/null +++ b/homeassistant/components/harmony/.translations/en.json @@ -0,0 +1,38 @@ +{ + "config": { + "title": "Logitech Harmony Hub", + "flow_title": "Logitech Harmony Hub {name}", + "step": { + "user": { + "title": "Setup Logitech Harmony Hub", + "data": { + "host": "Hostname or IP Address", + "name": "Hub Name" + } + }, + "link": { + "title": "Setup Logitech Harmony Hub", + "description": "Do you want to setup {name} ({host})?" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Device is already configured" + } + }, + "options": { + "step": { + "init": { + "description": "Adjust Harmony Hub Options", + "data": { + "activity": "The default activity to execute when none is specified.", + "delay_secs": "The delay between sending commands." + } + } + } + } +} diff --git a/homeassistant/components/harmony/__init__.py b/homeassistant/components/harmony/__init__.py index 12ccc78077e..0f9824231ea 100644 --- a/homeassistant/components/harmony/__init__.py +++ b/homeassistant/components/harmony/__init__.py @@ -1 +1,91 @@ -"""Support for Harmony devices.""" +"""The Logitech Harmony Hub integration.""" +import asyncio +import logging + +from homeassistant.components.remote import ATTR_ACTIVITY, ATTR_DELAY_SECS +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN, PLATFORMS +from .remote import DEVICES, HarmonyRemote + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Logitech Harmony Hub component.""" + hass.data.setdefault(DOMAIN, {}) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Logitech Harmony Hub from a config entry.""" + + conf = entry.data + address = conf[CONF_HOST] + name = conf.get(CONF_NAME) + activity = conf.get(ATTR_ACTIVITY) + delay_secs = conf.get(ATTR_DELAY_SECS) + + _LOGGER.info( + "Loading Harmony Platform: %s at %s, startup activity: %s", + name, + address, + activity, + ) + + harmony_conf_file = hass.config.path(f"harmony_{entry.unique_id}.conf") + try: + device = HarmonyRemote(name, address, activity, harmony_conf_file, delay_secs) + await device.connect() + except (asyncio.TimeoutError, ValueError, AttributeError): + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = device + DEVICES.append(device) + + entry.add_update_listener(_update_listener) + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def _update_listener(hass, entry): + """Handle options update.""" + + device = hass.data[DOMAIN][entry.entry_id] + + if ATTR_DELAY_SECS in entry.options: + device.delay_seconds = entry.options[ATTR_DELAY_SECS] + + if ATTR_ACTIVITY in entry.options: + device.default_activity = entry.options[ATTR_ACTIVITY] + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + # Shutdown a harmony remote for removal + device = hass.data[DOMAIN][entry.entry_id] + await device.shutdown() + + if unload_ok: + DEVICES.remove(hass.data[DOMAIN][entry.entry_id]) + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py new file mode 100644 index 00000000000..ddd52dfd008 --- /dev/null +++ b/homeassistant/components/harmony/config_flow.py @@ -0,0 +1,211 @@ +"""Config flow for Logitech Harmony Hub integration.""" +import logging +from urllib.parse import urlparse + +import aioharmony.exceptions as harmony_exceptions +from aioharmony.harmonyapi import HarmonyAPI +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.components import ssdp +from homeassistant.components.remote import ( + ATTR_ACTIVITY, + ATTR_DELAY_SECS, + DEFAULT_DELAY_SECS, +) +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import callback + +from .const import DOMAIN, UNIQUE_ID +from .util import find_unique_id_for_remote + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str}, extra=vol.ALLOW_EXTRA +) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + harmony = HarmonyAPI(ip_address=data[CONF_HOST]) + + _LOGGER.debug("harmony:%s", harmony) + + try: + if not await harmony.connect(): + await harmony.close() + raise CannotConnect + except harmony_exceptions.TimeOut: + raise CannotConnect + + unique_id = find_unique_id_for_remote(harmony) + await harmony.close() + + # As a last resort we get the name from the harmony client + # in the event a name was not provided. harmony.name is + # usually the ip address but it can be an empty string. + if CONF_NAME not in data or data[CONF_NAME] is None or data[CONF_NAME] == "": + data[CONF_NAME] = harmony.name + + return { + CONF_NAME: data[CONF_NAME], + CONF_HOST: data[CONF_HOST], + UNIQUE_ID: unique_id, + } + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Logitech Harmony Hub.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self): + """Initialize the Harmony config flow.""" + self.harmony_config = {} + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if "base" not in errors: + return await self._async_create_entry_from_valid_input(info, user_input) + + # Return form + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_ssdp(self, discovery_info): + """Handle a discovered Harmony device.""" + _LOGGER.debug("SSDP discovery_info: %s", discovery_info) + + parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) + friendly_name = discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME] + + # pylint: disable=no-member + self.context["title_placeholders"] = {"name": friendly_name} + + self.harmony_config = { + CONF_HOST: parsed_url.hostname, + CONF_NAME: friendly_name, + } + + if self._host_already_configured(self.harmony_config): + return self.async_abort(reason="already_configured") + + return await self.async_step_link() + + async def async_step_link(self, user_input=None): + """Attempt to link with the Harmony.""" + errors = {} + + if user_input is not None: + try: + info = await validate_input(self.hass, self.harmony_config) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if "base" not in errors: + return await self._async_create_entry_from_valid_input(info, user_input) + + return self.async_show_form( + step_id="link", + errors=errors, + description_placeholders={ + CONF_HOST: self.harmony_config[CONF_NAME], + CONF_NAME: self.harmony_config[CONF_HOST], + }, + ) + + async def async_step_import(self, user_input): + """Handle import.""" + return await self.async_step_user(user_input) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + async def _async_create_entry_from_valid_input(self, validated, user_input): + """Single path to create the config entry from validated input.""" + await self.async_set_unique_id(validated[UNIQUE_ID]) + if self._host_already_configured(validated): + return self.async_abort(reason="already_configured") + self._abort_if_unique_id_configured() + config_entry = self.async_create_entry( + title=validated[CONF_NAME], + data={CONF_NAME: validated[CONF_NAME], CONF_HOST: validated[CONF_HOST]}, + ) + # Options from yaml are preserved + options = _options_from_user_input(user_input) + if options: + config_entry["options"] = options + return config_entry + + def _host_already_configured(self, user_input): + """See if we already have a harmony matching user input configured.""" + existing_hosts = { + entry.data[CONF_HOST] for entry in self._async_current_entries() + } + return user_input[CONF_HOST] in existing_hosts + + +def _options_from_user_input(user_input): + options = {} + if ATTR_ACTIVITY in user_input: + options[ATTR_ACTIVITY] = user_input[ATTR_ACTIVITY] + if ATTR_DELAY_SECS in user_input: + options[ATTR_DELAY_SECS] = user_input[ATTR_DELAY_SECS] + return options + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for Harmony.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + remote = self.hass.data[DOMAIN][self.config_entry.entry_id] + + data_schema = vol.Schema( + { + vol.Optional( + ATTR_DELAY_SECS, + default=self.config_entry.options.get( + ATTR_DELAY_SECS, DEFAULT_DELAY_SECS + ), + ): vol.Coerce(float), + vol.Optional( + ATTR_ACTIVITY, default=self.config_entry.options.get(ATTR_ACTIVITY), + ): vol.In(remote.activity_names), + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/harmony/const.py b/homeassistant/components/harmony/const.py index 12e71050665..60542845bd0 100644 --- a/homeassistant/components/harmony/const.py +++ b/homeassistant/components/harmony/const.py @@ -2,3 +2,5 @@ DOMAIN = "harmony" SERVICE_SYNC = "sync" SERVICE_CHANGE_CHANNEL = "change_channel" +PLATFORMS = ["remote"] +UNIQUE_ID = "unique_id" diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index a0e8baa0b58..870e3f15044 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -4,5 +4,12 @@ "documentation": "https://www.home-assistant.io/integrations/harmony", "requirements": ["aioharmony==0.1.13"], "dependencies": [], - "codeowners": ["@ehendrix23"] + "codeowners": ["@ehendrix23","@bramkragten","@bdraco"], + "ssdp": [ + { + "manufacturer": "Logitech", + "deviceType": "urn:myharmony-com:device:harmony:1" + } + ], + "config_flow": true } diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index 126ce0ff992..a5e70b4d807 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -21,36 +21,29 @@ from homeassistant.components.remote import ( DEFAULT_DELAY_SECS, PLATFORM_SCHEMA, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - CONF_HOST, - CONF_NAME, - CONF_PORT, - EVENT_HOMEASSISTANT_STOP, -) -from homeassistant.exceptions import PlatformNotReady +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.util import slugify from .const import DOMAIN, SERVICE_CHANGE_CHANNEL, SERVICE_SYNC +from .util import find_unique_id_for_remote _LOGGER = logging.getLogger(__name__) ATTR_CHANNEL = "channel" ATTR_CURRENT_ACTIVITY = "current_activity" -DEFAULT_PORT = 8088 DEVICES = [] -CONF_DEVICE_CACHE = "harmony_device_cache" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(ATTR_ACTIVITY): cv.string, vol.Required(CONF_NAME): cv.string, vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): vol.Coerce(float), vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - } + # The client ignores port so lets not confuse the user by pretenting we do anything with this + }, + extra=vol.ALLOW_EXTRA, ) HARMONY_SYNC_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) @@ -65,65 +58,36 @@ HARMONY_CHANGE_CHANNEL_SCHEMA = vol.Schema( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Harmony platform.""" - activity = None - - if CONF_DEVICE_CACHE not in hass.data: - hass.data[CONF_DEVICE_CACHE] = [] if discovery_info: - # Find the discovered device in the list of user configurations - override = next( - ( - c - for c in hass.data[CONF_DEVICE_CACHE] - if c.get(CONF_NAME) == discovery_info.get(CONF_NAME) - ), - None, - ) - - port = DEFAULT_PORT - delay_secs = DEFAULT_DELAY_SECS - if override is not None: - activity = override.get(ATTR_ACTIVITY) - delay_secs = override.get(ATTR_DELAY_SECS) - port = override.get(CONF_PORT, DEFAULT_PORT) - - host = (discovery_info.get(CONF_NAME), discovery_info.get(CONF_HOST), port) - - # Ignore hub name when checking if this hub is known - ip and port only - if host[1:] in ((h.host, h.port) for h in DEVICES): - _LOGGER.debug("Discovered host already known: %s", host) - return - elif CONF_HOST in config: - host = (config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT)) - activity = config.get(ATTR_ACTIVITY) - delay_secs = config.get(ATTR_DELAY_SECS) - else: - hass.data[CONF_DEVICE_CACHE].append(config) + # Now handled by ssdp in the config flow return - name, address, port = host - _LOGGER.info( - "Loading Harmony Platform: %s at %s:%s, startup activity: %s", - name, - address, - port, - activity, + if CONF_HOST not in config: + _LOGGER.error( + "The harmony remote '%s' cannot be setup because configuration now requires a host when configured manually.", + config[CONF_NAME], + ) + return + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) ) - harmony_conf_file = hass.config.path(f"harmony_{slugify(name)}.conf") - try: - device = HarmonyRemote( - name, address, port, activity, harmony_conf_file, delay_secs - ) - if not await device.connect(): - raise PlatformNotReady - DEVICES.append(device) - async_add_entities([device]) - register_services(hass) - except (ValueError, AttributeError): - raise PlatformNotReady +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): + """Set up the Harmony config entry.""" + + device = hass.data[DOMAIN][entry.entry_id] + + _LOGGER.info("Harmony Remote: %s", device) + + async_add_entities([device]) + register_services(hass) def register_services(hass): @@ -165,11 +129,10 @@ async def _change_channel_service(service): class HarmonyRemote(remote.RemoteDevice): """Remote representation used to control a Harmony device.""" - def __init__(self, name, host, port, activity, out_path, delay_secs): + def __init__(self, name, host, activity, out_path, delay_secs): """Initialize HarmonyRemote class.""" self._name = name self.host = host - self.port = port self._state = None self._current_activity = None self._default_activity = activity @@ -178,6 +141,39 @@ class HarmonyRemote(remote.RemoteDevice): self._delay_secs = delay_secs self._available = False + @property + def delay_secs(self): + """Delay seconds between sending commands.""" + return self._delay_secs + + @delay_secs.setter + def delay_secs(self, delay_secs): + """Update the delay seconds (from options flow).""" + self._delay_secs = delay_secs + + @property + def default_activity(self): + """Activity used when non specified.""" + return self._default_activity + + @property + def activity_names(self): + """Names of all the remotes activities.""" + activities = [activity["label"] for activity in self._client.config["activity"]] + + # Remove both ways of representing PowerOff + if None in activities: + activities.remove(None) + if "PowerOff" in activities: + activities.remove("PowerOff") + + return activities + + @default_activity.setter + def default_activity(self, activity): + """Update the default activity (from options flow).""" + self._default_activity = activity + async def async_added_to_hass(self): """Complete the initialization.""" _LOGGER.debug("%s: Harmony Hub added", self._name) @@ -193,15 +189,34 @@ class HarmonyRemote(remote.RemoteDevice): # activity await self.new_config() - async def shutdown(_): - """Close connection on shutdown.""" - _LOGGER.debug("%s: Closing Harmony Hub", self._name) - try: - await self._client.close() - except aioexc.TimeOut: - _LOGGER.warning("%s: Disconnect timed-out", self._name) + async def shutdown(self): + """Close connection on shutdown.""" + _LOGGER.debug("%s: Closing Harmony Hub", self._name) + try: + await self._client.close() + except aioexc.TimeOut: + _LOGGER.warning("%s: Disconnect timed-out", self._name) - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) + @property + def device_info(self): + """Return device info.""" + model = "Harmony Hub" + if "ethernetStatus" in self._client.hub_config.info: + model = "Harmony Hub Pro 2400" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "manufacturer": "Logitech", + "sw_version": self._client.hub_config.info.get( + "hubSwVersion", self._client.fw_version + ), + "name": self.name, + "model": model, + } + + @property + def unique_id(self): + """Return the unique id.""" + return find_unique_id_for_remote(self._client) @property def name(self): @@ -239,7 +254,6 @@ class HarmonyRemote(remote.RemoteDevice): except aioexc.TimeOut: _LOGGER.warning("%s: Connection timed-out", self._name) return False - return True def new_activity(self, activity_info: tuple) -> None: diff --git a/homeassistant/components/harmony/strings.json b/homeassistant/components/harmony/strings.json new file mode 100644 index 00000000000..b183e067101 --- /dev/null +++ b/homeassistant/components/harmony/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "title": "Logitech Harmony Hub", + "flow_title": "Logitech Harmony Hub {name}", + "step": { + "user": { + "title": "Setup Logitech Harmony Hub", + "data": { + "host": "Hostname or IP Address", + "name": "Hub Name" + } + }, + "link": { + "title": "Setup Logitech Harmony Hub", + "description": "Do you want to setup {name} ({host})?" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Device is already configured" + } + }, + "options": { + "step": { + "init": { + "description": "Adjust Harmony Hub Options", + "data": { + "activity": "The default activity to execute when none is specified.", + "delay_secs": "The delay between sending commands." + } + } + } + } +} diff --git a/homeassistant/components/harmony/util.py b/homeassistant/components/harmony/util.py new file mode 100644 index 00000000000..1aa29548f7c --- /dev/null +++ b/homeassistant/components/harmony/util.py @@ -0,0 +1,15 @@ +"""The Logitech Harmony Hub integration utils.""" +from aioharmony.harmonyapi import HarmonyAPI as HarmonyClient + + +def find_unique_id_for_remote(harmony: HarmonyClient): + """Find the unique id for both websocket and xmpp clients.""" + websocket_unique_id = harmony.hub_config.info.get("activeRemoteId") + if websocket_unique_id is not None: + return websocket_unique_id + + xmpp_unique_id = harmony.config.get("global", {}).get("timeStampHash") + if not xmpp_unique_id: + return None + + return xmpp_unique_id.split(";")[-1] diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 0a598ae345d..580c0a3b152 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.loader import bind_hass -# mypy: allow-untyped-calls +# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index fb2f2bdb7a9..ac24ecb9209 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -40,6 +40,7 @@ FLOWS = [ "gpslogger", "griddy", "hangouts", + "harmony", "heos", "hisense_aehw4a1", "homekit_controller", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 1df265bffe5..c9832ea2d86 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -17,6 +17,12 @@ SSDP = { "manufacturer": "DIRECTV" } ], + "harmony": [ + { + "deviceType": "urn:myharmony-com:device:harmony:1", + "manufacturer": "Logitech" + } + ], "heos": [ { "st": "urn:schemas-denon-com:device:ACT-Denon:1" diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 614ad22605e..d08e44cc579 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -64,6 +64,9 @@ aioesphomeapi==2.6.1 # homeassistant.components.freebox aiofreepybox==0.0.8 +# homeassistant.components.harmony +aioharmony==0.1.13 + # homeassistant.components.homekit_controller aiohomekit[IP]==0.2.34 diff --git a/tests/components/harmony/__init__.py b/tests/components/harmony/__init__.py new file mode 100644 index 00000000000..f427677b40a --- /dev/null +++ b/tests/components/harmony/__init__.py @@ -0,0 +1 @@ +"""Tests for the Logitech Harmony Hub integration.""" diff --git a/tests/components/harmony/test_config_flow.py b/tests/components/harmony/test_config_flow.py new file mode 100644 index 00000000000..39e11d30afe --- /dev/null +++ b/tests/components/harmony/test_config_flow.py @@ -0,0 +1,148 @@ +"""Test the Logitech Harmony Hub config flow.""" +from asynctest import CoroutineMock, MagicMock, patch + +from homeassistant import config_entries, setup +from homeassistant.components.harmony.config_flow import CannotConnect +from homeassistant.components.harmony.const import DOMAIN + + +def _get_mock_harmonyapi(connect=None, close=None): + harmonyapi_mock = MagicMock() + type(harmonyapi_mock).connect = CoroutineMock(return_value=connect) + type(harmonyapi_mock).close = CoroutineMock(return_value=close) + + return harmonyapi_mock + + +async def test_user_form(hass): + """Test we get the user form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + harmonyapi = _get_mock_harmonyapi(connect=True) + with patch( + "homeassistant.components.harmony.config_flow.HarmonyAPI", + return_value=harmonyapi, + ), patch( + "homeassistant.components.harmony.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.harmony.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "1.2.3.4", "name": "friend"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "friend" + assert result2["data"] == { + "host": "1.2.3.4", + "name": "friend", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_import(hass): + """Test we get the form with import source.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + harmonyapi = _get_mock_harmonyapi(connect=True) + with patch( + "homeassistant.components.harmony.config_flow.HarmonyAPI", + return_value=harmonyapi, + ), patch( + "homeassistant.components.harmony.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.harmony.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "host": "1.2.3.4", + "name": "friend", + "activity": "Watch TV", + "delay_secs": 0.9, + }, + ) + + assert result["type"] == "create_entry" + assert result["title"] == "friend" + assert result["data"] == { + "host": "1.2.3.4", + "name": "friend", + } + assert result["options"] == { + "activity": "Watch TV", + "delay_secs": 0.9, + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_ssdp(hass): + """Test we get the form with ssdp source.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data={ + "friendlyName": "Harmony Hub", + "ssdp_location": "http://192.168.209.238:8088/description", + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "link" + assert result["errors"] == {} + + harmonyapi = _get_mock_harmonyapi(connect=True) + with patch( + "homeassistant.components.harmony.config_flow.HarmonyAPI", + return_value=harmonyapi, + ), patch( + "homeassistant.components.harmony.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.harmony.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {},) + + assert result2["type"] == "create_entry" + assert result2["title"] == "Harmony Hub" + assert result2["data"] == { + "host": "192.168.209.238", + "name": "Harmony Hub", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.harmony.config_flow.HarmonyAPI", + side_effect=CannotConnect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.2.3.4", + "name": "friend", + "activity": "Watch TV", + "delay_secs": 0.2, + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"}