diff --git a/.coveragerc b/.coveragerc index d14bf41195e6fd0b07bac950f4bcf0a8cf1d3b22..b859e229e74082611c6fbd5b69f74cf42bed2a9a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -358,7 +358,10 @@ omit = homeassistant/components/hangouts/hangouts_bot.py homeassistant/components/hangouts/hangups_utils.py homeassistant/components/harman_kardon_avr/media_player.py - homeassistant/components/harmony/* + homeassistant/components/harmony/const.py + homeassistant/components/harmony/data.py + homeassistant/components/harmony/remote.py + homeassistant/components/harmony/util.py homeassistant/components/haveibeenpwned/sensor.py homeassistant/components/hdmi_cec/* homeassistant/components/heatmiser/climate.py diff --git a/CODEOWNERS b/CODEOWNERS index 8c9ffeea599a09c4bd1d081b9429b6eeef2006f1..03912a0dec0464997145a7946868da03eec6aee3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -180,7 +180,7 @@ homeassistant/components/griddy/* @bdraco homeassistant/components/group/* @home-assistant/core homeassistant/components/growatt_server/* @indykoning homeassistant/components/guardian/* @bachya -homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco +homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco @mkeesey homeassistant/components/hassio/* @home-assistant/supervisor homeassistant/components/hdmi_cec/* @newAM homeassistant/components/heatmiser/* @andylockran diff --git a/homeassistant/components/harmony/__init__.py b/homeassistant/components/harmony/__init__.py index e425b5ce94a3747750c3724ae2a12e5e92c70383..6ba63ee0f81eaa3e4d5286699248f4a4c56629f7 100644 --- a/homeassistant/components/harmony/__init__.py +++ b/homeassistant/components/harmony/__init__.py @@ -1,11 +1,7 @@ """The Logitech Harmony Hub integration.""" import asyncio -from homeassistant.components.remote import ( - ATTR_ACTIVITY, - ATTR_DELAY_SECS, - DEFAULT_DELAY_SECS, -) +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, callback @@ -13,7 +9,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import DOMAIN, HARMONY_OPTIONS_UPDATE, PLATFORMS -from .remote import HarmonyRemote +from .data import HarmonyData async def async_setup(hass: HomeAssistant, config: dict): @@ -33,22 +29,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): address = entry.data[CONF_HOST] name = entry.data[CONF_NAME] - activity = entry.options.get(ATTR_ACTIVITY) - delay_secs = entry.options.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) - - harmony_conf_file = hass.config.path(f"harmony_{entry.unique_id}.conf") + data = HarmonyData(hass, address, name, entry.unique_id) try: - device = HarmonyRemote( - name, entry.unique_id, address, activity, harmony_conf_file, delay_secs - ) - connected_ok = await device.connect() + connected_ok = await data.connect() except (asyncio.TimeoutError, ValueError, AttributeError) as err: raise ConfigEntryNotReady from err if not connected_ok: raise ConfigEntryNotReady - hass.data[DOMAIN][entry.entry_id] = device + hass.data[DOMAIN][entry.entry_id] = data entry.add_update_listener(_update_listener) @@ -92,8 +82,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): ) # Shutdown a harmony remote for removal - device = hass.data[DOMAIN][entry.entry_id] - await device.shutdown() + data = hass.data[DOMAIN][entry.entry_id] + await data.shutdown() if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/harmony/connection_state.py b/homeassistant/components/harmony/connection_state.py new file mode 100644 index 0000000000000000000000000000000000000000..9706ba28776d0da3b272c1baa7e0837e07bbbdd5 --- /dev/null +++ b/homeassistant/components/harmony/connection_state.py @@ -0,0 +1,44 @@ +"""Mixin class for handling connection state changes.""" +import logging + +from homeassistant.helpers.event import async_call_later + +_LOGGER = logging.getLogger(__name__) + +TIME_MARK_DISCONNECTED = 10 + + +class ConnectionStateMixin: + """Base implementation for connection state handling.""" + + def __init__(self): + """Initialize this mixin instance.""" + super().__init__() + self._unsub_mark_disconnected = None + + async def got_connected(self, _=None): + """Notification that we're connected to the HUB.""" + _LOGGER.debug("%s: connected to the HUB", self._name) + self.async_write_ha_state() + + self._clear_disconnection_delay() + + async def got_disconnected(self, _=None): + """Notification that we're disconnected from the HUB.""" + _LOGGER.debug("%s: disconnected from the HUB", self._name) + # We're going to wait for 10 seconds before announcing we're + # unavailable, this to allow a reconnection to happen. + self._unsub_mark_disconnected = async_call_later( + self.hass, TIME_MARK_DISCONNECTED, self._mark_disconnected_if_unavailable + ) + + def _clear_disconnection_delay(self): + if self._unsub_mark_disconnected: + self._unsub_mark_disconnected() + self._unsub_mark_disconnected = None + + def _mark_disconnected_if_unavailable(self, _): + self._unsub_mark_disconnected = None + if not self.available: + # Still disconnected. Let the state engine know. + self.async_write_ha_state() diff --git a/homeassistant/components/harmony/const.py b/homeassistant/components/harmony/const.py index f6315b57b57a498337adeae8763ff0e2ba1340b9..ee4a454847e629fe67b7ace0e73402a6eea749f7 100644 --- a/homeassistant/components/harmony/const.py +++ b/homeassistant/components/harmony/const.py @@ -2,7 +2,7 @@ DOMAIN = "harmony" SERVICE_SYNC = "sync" SERVICE_CHANGE_CHANNEL = "change_channel" -PLATFORMS = ["remote"] +PLATFORMS = ["remote", "switch"] UNIQUE_ID = "unique_id" ACTIVITY_POWER_OFF = "PowerOff" HARMONY_OPTIONS_UPDATE = "harmony_options_update" diff --git a/homeassistant/components/harmony/data.py b/homeassistant/components/harmony/data.py new file mode 100644 index 0000000000000000000000000000000000000000..6c3ad874fa9d2ce56df967a21f9ea1322de41ab0 --- /dev/null +++ b/homeassistant/components/harmony/data.py @@ -0,0 +1,251 @@ +"""Harmony data object which contains the Harmony Client.""" + +import logging +from typing import Iterable + +from aioharmony.const import ClientCallbackType, SendCommandDevice +import aioharmony.exceptions as aioexc +from aioharmony.harmonyapi import HarmonyAPI as HarmonyClient + +from .const import ACTIVITY_POWER_OFF +from .subscriber import HarmonySubscriberMixin + +_LOGGER = logging.getLogger(__name__) + + +class HarmonyData(HarmonySubscriberMixin): + """HarmonyData registers for Harmony hub updates.""" + + def __init__(self, hass, address: str, name: str, unique_id: str): + """Initialize a data object.""" + super().__init__(hass) + self._name = name + self._unique_id = unique_id + self._available = False + + callbacks = { + "config_updated": self._config_updated, + "connect": self._connected, + "disconnect": self._disconnected, + "new_activity_starting": self._activity_starting, + "new_activity": self._activity_started, + } + self._client = HarmonyClient( + ip_address=address, callbacks=ClientCallbackType(**callbacks) + ) + + @property + def activity_names(self): + """Names of all the remotes activities.""" + activity_infos = self._client.config.get("activity", []) + activities = [activity["label"] for activity in activity_infos] + + # Remove both ways of representing PowerOff + if None in activities: + activities.remove(None) + if ACTIVITY_POWER_OFF in activities: + activities.remove(ACTIVITY_POWER_OFF) + + return activities + + @property + def device_names(self): + """Names of all of the devices connected to the hub.""" + device_infos = self._client.config.get("device", []) + devices = [device["label"] for device in device_infos] + + return devices + + @property + def name(self): + """Return the Harmony device's name.""" + return self._name + + @property + def unique_id(self): + """Return the Harmony device's unique_id.""" + return self._unique_id + + @property + def json_config(self): + """Return the hub config as json.""" + if self._client.config is None: + return None + return self._client.json_config + + @property + def available(self) -> bool: + """Return if connected to the hub.""" + return self._available + + @property + def current_activity(self) -> tuple: + """Return the current activity tuple.""" + return self._client.current_activity + + def device_info(self, domain: str): + """Return hub 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, + } + + async def connect(self) -> bool: + """Connect to the Harmony Hub.""" + _LOGGER.debug("%s: Connecting", self._name) + try: + if not await self._client.connect(): + _LOGGER.warning("%s: Unable to connect to HUB", self._name) + await self._client.close() + return False + except aioexc.TimeOut: + _LOGGER.warning("%s: Connection timed-out", self._name) + return False + return True + + 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) + + async def async_start_activity(self, activity: str): + """Start an activity from the Harmony device.""" + + if not activity: + _LOGGER.error("%s: No activity specified with turn_on service", self.name) + return + + activity_id = None + activity_name = None + + if activity.isdigit() or activity == "-1": + _LOGGER.debug("%s: Activity is numeric", self.name) + activity_name = self._client.get_activity_name(int(activity)) + if activity_name: + activity_id = activity + + if activity_id is None: + _LOGGER.debug("%s: Find activity ID based on name", self.name) + activity_name = str(activity) + activity_id = self._client.get_activity_id(activity_name) + + if activity_id is None: + _LOGGER.error("%s: Activity %s is invalid", self.name, activity) + return + + _, current_activity_name = self.current_activity + if current_activity_name == activity_name: + # Automations or HomeKit may turn the device on multiple times + # when the current activity is already active which will cause + # harmony to loose state. This behavior is unexpected as turning + # the device on when its already on isn't expected to reset state. + _LOGGER.debug( + "%s: Current activity is already %s", self.name, activity_name + ) + return + + try: + await self._client.start_activity(activity_id) + except aioexc.TimeOut: + _LOGGER.error("%s: Starting activity %s timed-out", self.name, activity) + + async def async_power_off(self): + """Start the PowerOff activity.""" + _LOGGER.debug("%s: Turn Off", self.name) + try: + await self._client.power_off() + except aioexc.TimeOut: + _LOGGER.error("%s: Powering off timed-out", self.name) + + async def async_send_command( + self, + commands: Iterable[str], + device: str, + num_repeats: int, + delay_secs: float, + hold_secs: float, + ): + """Send a list of commands to one device.""" + device_id = None + if device.isdigit(): + _LOGGER.debug("%s: Device %s is numeric", self.name, device) + if self._client.get_device_name(int(device)): + device_id = device + + if device_id is None: + _LOGGER.debug( + "%s: Find device ID %s based on device name", self.name, device + ) + device_id = self._client.get_device_id(str(device).strip()) + + if device_id is None: + _LOGGER.error("%s: Device %s is invalid", self.name, device) + return + + _LOGGER.debug( + "Sending commands to device %s holding for %s seconds " + "with a delay of %s seconds", + device, + hold_secs, + delay_secs, + ) + + # Creating list of commands to send. + snd_cmnd_list = [] + for _ in range(num_repeats): + for single_command in commands: + send_command = SendCommandDevice( + device=device_id, command=single_command, delay=hold_secs + ) + snd_cmnd_list.append(send_command) + if delay_secs > 0: + snd_cmnd_list.append(float(delay_secs)) + + _LOGGER.debug("%s: Sending commands", self.name) + try: + result_list = await self._client.send_commands(snd_cmnd_list) + except aioexc.TimeOut: + _LOGGER.error("%s: Sending commands timed-out", self.name) + return + + for result in result_list: + _LOGGER.error( + "Sending command %s to device %s failed with code %s: %s", + result.command.command, + result.command.device, + result.code, + result.msg, + ) + + async def change_channel(self, channel: int): + """Change the channel using Harmony remote.""" + _LOGGER.debug("%s: Changing channel to %s", self.name, channel) + try: + await self._client.change_channel(channel) + except aioexc.TimeOut: + _LOGGER.error("%s: Changing channel to %s timed-out", self.name, channel) + + async def sync(self) -> bool: + """Sync the Harmony device with the web service. + + Returns True if the sync was successful. + """ + _LOGGER.debug("%s: Syncing hub with Harmony cloud", self.name) + try: + await self._client.sync() + except aioexc.TimeOut: + _LOGGER.error("%s: Syncing hub with Harmony cloud timed-out", self.name) + return False + else: + return True diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index 4d8b83f464319eb7f04e272c1206a8cdfa7b18b8..7509f3d4f4df6c5414e0beb3ff8d00287c38189a 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -3,12 +3,13 @@ "name": "Logitech Harmony Hub", "documentation": "https://www.home-assistant.io/integrations/harmony", "requirements": ["aioharmony==0.2.6"], - "codeowners": ["@ehendrix23", "@bramkragten", "@bdraco"], + "codeowners": ["@ehendrix23", "@bramkragten", "@bdraco", "@mkeesey"], "ssdp": [ { "manufacturer": "Logitech", "deviceType": "urn:myharmony-com:device:harmony:1" } ], + "dependencies": ["remote", "switch"], "config_flow": true } diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index ff7825013e8f5d32bf55a8bab0e0e30d82a2b5f2..b9205a4befb2f33715af949d4c0cce429f297507 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -1,11 +1,7 @@ """Support for Harmony Hub devices.""" -import asyncio import json import logging -from aioharmony.const import ClientCallbackType -import aioharmony.exceptions as aioexc -from aioharmony.harmonyapi import HarmonyAPI as HarmonyClient, SendCommandDevice import voluptuous as vol from homeassistant.components import remote @@ -27,6 +23,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity +from .connection_state import ConnectionStateMixin from .const import ( ACTIVITY_POWER_OFF, ATTR_ACTIVITY_LIST, @@ -41,12 +38,12 @@ from .const import ( SERVICE_SYNC, UNIQUE_ID, ) +from .subscriber import HarmonyCallback from .util import ( find_best_name_for_remote, find_matching_config_entries_for_host, find_unique_id_for_remote, get_harmony_client_if_available, - list_names_from_hublist, ) _LOGGER = logging.getLogger(__name__) @@ -113,10 +110,15 @@ async def async_setup_entry( ): """Set up the Harmony config entry.""" - device = hass.data[DOMAIN][entry.entry_id] + data = hass.data[DOMAIN][entry.entry_id] - _LOGGER.debug("Harmony Remote: %s", device) + _LOGGER.debug("HarmonyData : %s", data) + default_activity = entry.options.get(ATTR_ACTIVITY) + delay_secs = entry.options.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) + + harmony_conf_file = hass.config.path(f"harmony_{entry.unique_id}.conf") + device = HarmonyRemote(data, default_activity, delay_secs, harmony_conf_file) async_add_entities([device]) platform = entity_platform.current_platform.get() @@ -131,37 +133,23 @@ async def async_setup_entry( ) -class HarmonyRemote(remote.RemoteEntity, RestoreEntity): +class HarmonyRemote(ConnectionStateMixin, remote.RemoteEntity, RestoreEntity): """Remote representation used to control a Harmony device.""" - def __init__(self, name, unique_id, host, activity, out_path, delay_secs): + def __init__(self, data, activity, delay_secs, out_path): """Initialize HarmonyRemote class.""" - self._name = name - self.host = host + super().__init__() + self._data = data + self._name = data.name self._state = None self._current_activity = ACTIVITY_POWER_OFF self.default_activity = activity self._activity_starting = None self._is_initial_update = True - self._client = HarmonyClient(ip_address=host) - self._config_path = out_path self.delay_secs = delay_secs - self._available = False - self._unique_id = unique_id + self._unique_id = data.unique_id self._last_activity = None - - @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 ACTIVITY_POWER_OFF in activities: - activities.remove(ACTIVITY_POWER_OFF) - - return activities + self._config_path = out_path async def _async_update_options(self, data): """Change options when the options flow does.""" @@ -171,15 +159,16 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): if ATTR_ACTIVITY in data: self.default_activity = data[ATTR_ACTIVITY] - def _update_callbacks(self): + def _setup_callbacks(self): callbacks = { + "connected": self.got_connected, + "disconnected": self.got_disconnected, "config_updated": self.new_config, - "connect": self.got_connected, - "disconnect": self.got_disconnected, - "new_activity_starting": self.new_activity, - "new_activity": self._new_activity_finished, + "activity_starting": self.new_activity, + "activity_started": self._new_activity_finished, } - self._client.callbacks = ClientCallbackType(**callbacks) + + self.async_on_remove(self._data.async_subscribe(HarmonyCallback(**callbacks))) def _new_activity_finished(self, activity_info: tuple) -> None: """Call for finished updated current activity.""" @@ -191,8 +180,9 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): await super().async_added_to_hass() _LOGGER.debug("%s: Harmony Hub added", self._name) - # Register the callbacks - self._update_callbacks() + + self.async_on_remove(self._clear_disconnection_delay) + self._setup_callbacks() self.async_on_remove( async_dispatcher_connect( @@ -219,29 +209,10 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): self._last_activity = last_state.attributes[ATTR_LAST_ACTIVITY] - 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) - @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, - } + self._data.device_info(DOMAIN) @property def unique_id(self): @@ -264,10 +235,8 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): return { ATTR_ACTIVITY_STARTING: self._activity_starting, ATTR_CURRENT_ACTIVITY: self._current_activity, - ATTR_ACTIVITY_LIST: list_names_from_hublist( - self._client.hub_config.activities - ), - ATTR_DEVICES_LIST: list_names_from_hublist(self._client.hub_config.devices), + ATTR_ACTIVITY_LIST: self._data.activity_names, + ATTR_DEVICES_LIST: self._data.device_names, ATTR_LAST_ACTIVITY: self._last_activity, } @@ -279,20 +248,7 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): @property def available(self): """Return True if connected to Hub, otherwise False.""" - return self._available - - async def connect(self): - """Connect to the Harmony HUB.""" - _LOGGER.debug("%s: Connecting", self._name) - try: - if not await self._client.connect(): - _LOGGER.warning("%s: Unable to connect to HUB", self._name) - await self._client.close() - return False - except aioexc.TimeOut: - _LOGGER.warning("%s: Connection timed-out", self._name) - return False - return True + return self._data.available def new_activity(self, activity_info: tuple) -> None: """Call for updating the current activity.""" @@ -309,34 +265,14 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): # when turning on self._last_activity = activity_name self._state = bool(activity_id != -1) - self._available = True self.async_write_ha_state() async def new_config(self, _=None): """Call for updating the current activity.""" _LOGGER.debug("%s: configuration has been updated", self._name) - self.new_activity(self._client.current_activity) + self.new_activity(self._data.current_activity) await self.hass.async_add_executor_job(self.write_config_file) - async def got_connected(self, _=None): - """Notification that we're connected to the HUB.""" - _LOGGER.debug("%s: connected to the HUB", self._name) - if not self._available: - # We were disconnected before. - await self.new_config() - - async def got_disconnected(self, _=None): - """Notification that we're disconnected from the HUB.""" - _LOGGER.debug("%s: disconnected from the HUB", self._name) - self._available = False - # We're going to wait for 10 seconds before announcing we're - # unavailable, this to allow a reconnection to happen. - await asyncio.sleep(10) - - if not self._available: - # Still disconnected. Let the state engine know. - self.async_write_ha_state() - async def async_turn_on(self, **kwargs): """Start an activity from the Harmony device.""" _LOGGER.debug("%s: Turn On", self.name) @@ -347,55 +283,18 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): if self._last_activity: activity = self._last_activity else: - all_activities = list_names_from_hublist( - self._client.hub_config.activities - ) + all_activities = self._data.activity_names if all_activities: activity = all_activities[0] if activity: - activity_id = None - activity_name = None - - if activity.isdigit() or activity == "-1": - _LOGGER.debug("%s: Activity is numeric", self.name) - activity_name = self._client.get_activity_name(int(activity)) - if activity_name: - activity_id = activity - - if activity_id is None: - _LOGGER.debug("%s: Find activity ID based on name", self.name) - activity_name = str(activity) - activity_id = self._client.get_activity_id(activity_name) - - if activity_id is None: - _LOGGER.error("%s: Activity %s is invalid", self.name, activity) - return - - if self._current_activity == activity_name: - # Automations or HomeKit may turn the device on multiple times - # when the current activity is already active which will cause - # harmony to loose state. This behavior is unexpected as turning - # the device on when its already on isn't expected to reset state. - _LOGGER.debug( - "%s: Current activity is already %s", self.name, activity_name - ) - return - - try: - await self._client.start_activity(activity_id) - except aioexc.TimeOut: - _LOGGER.error("%s: Starting activity %s timed-out", self.name, activity) + await self._data.async_start_activity(activity) else: _LOGGER.error("%s: No activity specified with turn_on service", self.name) async def async_turn_off(self, **kwargs): """Start the PowerOff activity.""" - _LOGGER.debug("%s: Turn Off", self.name) - try: - await self._client.power_off() - except aioexc.TimeOut: - _LOGGER.error("%s: Powering off timed-out", self.name) + await self._data.async_power_off() async def async_send_command(self, command, **kwargs): """Send a list of commands to one device.""" @@ -405,90 +304,38 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): _LOGGER.error("%s: Missing required argument: device", self.name) return - device_id = None - if device.isdigit(): - _LOGGER.debug("%s: Device %s is numeric", self.name, device) - if self._client.get_device_name(int(device)): - device_id = device - - if device_id is None: - _LOGGER.debug( - "%s: Find device ID %s based on device name", self.name, device - ) - device_id = self._client.get_device_id(str(device).strip()) - - if device_id is None: - _LOGGER.error("%s: Device %s is invalid", self.name, device) - return - num_repeats = kwargs[ATTR_NUM_REPEATS] delay_secs = kwargs.get(ATTR_DELAY_SECS, self.delay_secs) hold_secs = kwargs[ATTR_HOLD_SECS] - _LOGGER.debug( - "Sending commands to device %s holding for %s seconds " - "with a delay of %s seconds", - device, - hold_secs, - delay_secs, + await self._data.async_send_command( + command, device, num_repeats, delay_secs, hold_secs ) - # Creating list of commands to send. - snd_cmnd_list = [] - for _ in range(num_repeats): - for single_command in command: - send_command = SendCommandDevice( - device=device_id, command=single_command, delay=hold_secs - ) - snd_cmnd_list.append(send_command) - if delay_secs > 0: - snd_cmnd_list.append(float(delay_secs)) - - _LOGGER.debug("%s: Sending commands", self.name) - try: - result_list = await self._client.send_commands(snd_cmnd_list) - except aioexc.TimeOut: - _LOGGER.error("%s: Sending commands timed-out", self.name) - return - - for result in result_list: - _LOGGER.error( - "Sending command %s to device %s failed with code %s: %s", - result.command.command, - result.command.device, - result.code, - result.msg, - ) - async def change_channel(self, channel): """Change the channel using Harmony remote.""" - _LOGGER.debug("%s: Changing channel to %s", self.name, channel) - try: - await self._client.change_channel(channel) - except aioexc.TimeOut: - _LOGGER.error("%s: Changing channel to %s timed-out", self.name, channel) + await self._data.change_channel(channel) async def sync(self): """Sync the Harmony device with the web service.""" - _LOGGER.debug("%s: Syncing hub with Harmony cloud", self.name) - try: - await self._client.sync() - except aioexc.TimeOut: - _LOGGER.error("%s: Syncing hub with Harmony cloud timed-out", self.name) - else: + if await self._data.sync(): await self.hass.async_add_executor_job(self.write_config_file) def write_config_file(self): - """Write Harmony configuration file.""" + """Write Harmony configuration file. + + This is a handy way for users to figure out the available commands for automations. + """ _LOGGER.debug( "%s: Writing hub configuration to file: %s", self.name, self._config_path ) - if self._client.config is None: + json_config = self._data.json_config + if json_config is None: _LOGGER.warning("%s: No configuration received from hub", self.name) return try: with open(self._config_path, "w+", encoding="utf-8") as file_out: - json.dump(self._client.json_config, file_out, sort_keys=True, indent=4) + json.dump(json_config, file_out, sort_keys=True, indent=4) except OSError as exc: _LOGGER.error( "%s: Unable to write HUB configuration to %s: %s", diff --git a/homeassistant/components/harmony/subscriber.py b/homeassistant/components/harmony/subscriber.py new file mode 100644 index 0000000000000000000000000000000000000000..d3bed33d5606697734c38dc4769b616cbe8e199d --- /dev/null +++ b/homeassistant/components/harmony/subscriber.py @@ -0,0 +1,77 @@ +"""Mixin class for handling harmony callback subscriptions.""" + +import logging +from typing import Any, Callable, NamedTuple, Optional + +from homeassistant.core import callback + +_LOGGER = logging.getLogger(__name__) + +NoParamCallback = Optional[Callable[[object], Any]] +ActivityCallback = Optional[Callable[[object, tuple], Any]] + + +class HarmonyCallback(NamedTuple): + """Callback type for Harmony Hub notifications.""" + + connected: NoParamCallback + disconnected: NoParamCallback + config_updated: NoParamCallback + activity_starting: ActivityCallback + activity_started: ActivityCallback + + +class HarmonySubscriberMixin: + """Base implementation for a subscriber.""" + + def __init__(self, hass): + """Initialize an subscriber.""" + super().__init__() + self._hass = hass + self._subscriptions = [] + + @callback + def async_subscribe(self, update_callbacks: HarmonyCallback) -> Callable: + """Add a callback subscriber.""" + self._subscriptions.append(update_callbacks) + + def _unsubscribe(): + self.async_unsubscribe(update_callbacks) + + return _unsubscribe + + @callback + def async_unsubscribe(self, update_callback: HarmonyCallback): + """Remove a callback subscriber.""" + self._subscriptions.remove(update_callback) + + def _config_updated(self, _=None) -> None: + _LOGGER.debug("config_updated") + self._call_callbacks("config_updated") + + def _connected(self, _=None) -> None: + _LOGGER.debug("connected") + self._available = True + self._call_callbacks("connected") + + def _disconnected(self, _=None) -> None: + _LOGGER.debug("disconnected") + self._available = False + self._call_callbacks("disconnected") + + def _activity_starting(self, activity_info: tuple) -> None: + _LOGGER.debug("activity %s starting", activity_info) + self._call_callbacks("activity_starting", activity_info) + + def _activity_started(self, activity_info: tuple) -> None: + _LOGGER.debug("activity %s started", activity_info) + self._call_callbacks("activity_started", activity_info) + + def _call_callbacks(self, callback_func_name: str, argument: tuple = None): + for subscription in self._subscriptions: + current_callback = getattr(subscription, callback_func_name) + if current_callback: + if argument: + self._hass.async_run_job(current_callback, argument) + else: + self._hass.async_run_job(current_callback) diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py new file mode 100644 index 0000000000000000000000000000000000000000..5fae07c431ba3524d405e795d404b6cde1d74dc4 --- /dev/null +++ b/homeassistant/components/harmony/switch.py @@ -0,0 +1,87 @@ +"""Support for Harmony Hub activities.""" +import logging + +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import CONF_NAME + +from .connection_state import ConnectionStateMixin +from .const import DOMAIN +from .data import HarmonyData +from .subscriber import HarmonyCallback + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up harmony activity switches.""" + data = hass.data[DOMAIN][entry.entry_id] + activities = data.activity_names + + switches = [] + for activity in activities: + _LOGGER.debug("creating switch for activity: %s", activity) + name = f"{entry.data[CONF_NAME]} {activity}" + switches.append(HarmonyActivitySwitch(name, activity, data)) + + async_add_entities(switches, True) + + +class HarmonyActivitySwitch(ConnectionStateMixin, SwitchEntity): + """Switch representation of a Harmony activity.""" + + def __init__(self, name: str, activity: str, data: HarmonyData): + """Initialize HarmonyActivitySwitch class.""" + super().__init__() + self._name = name + self._activity = activity + self._data = data + + @property + def name(self): + """Return the Harmony activity's name.""" + return self._name + + @property + def unique_id(self): + """Return the unique id.""" + return f"{self._data.unique_id}-{self._activity}" + + @property + def is_on(self): + """Return if the current activity is the one for this switch.""" + _, activity_name = self._data.current_activity + return activity_name == self._activity + + @property + def should_poll(self): + """Return that we shouldn't be polled.""" + return False + + @property + def available(self): + """Return True if we're connected to the Hub, otherwise False.""" + return self._data.available + + async def async_turn_on(self, **kwargs): + """Start this activity.""" + await self._data.async_start_activity(self._activity) + + async def async_turn_off(self, **kwargs): + """Stop this activity.""" + await self._data.async_power_off() + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + + callbacks = { + "connected": self.got_connected, + "disconnected": self.got_disconnected, + "activity_starting": self._activity_update, + "activity_started": self._activity_update, + "config_updated": None, + } + + self.async_on_remove(self._data.async_subscribe(HarmonyCallback(**callbacks))) + + def _activity_update(self, activity_info: tuple): + self.async_write_ha_state() diff --git a/tests/components/harmony/conftest.py b/tests/components/harmony/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..e758a2795a94e35a3578583bfb761c8d17243d45 --- /dev/null +++ b/tests/components/harmony/conftest.py @@ -0,0 +1,147 @@ +"""Fixtures for harmony tests.""" +import logging +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + +from aioharmony.const import ClientCallbackType +import pytest + +from homeassistant.components.harmony.const import ACTIVITY_POWER_OFF + +_LOGGER = logging.getLogger(__name__) + +WATCH_TV_ACTIVITY_ID = 123 +PLAY_MUSIC_ACTIVITY_ID = 456 + +ACTIVITIES_TO_IDS = { + ACTIVITY_POWER_OFF: -1, + "Watch TV": WATCH_TV_ACTIVITY_ID, + "Play Music": PLAY_MUSIC_ACTIVITY_ID, +} + +IDS_TO_ACTIVITIES = { + -1: ACTIVITY_POWER_OFF, + WATCH_TV_ACTIVITY_ID: "Watch TV", + PLAY_MUSIC_ACTIVITY_ID: "Play Music", +} + +TV_DEVICE_ID = 1234 +TV_DEVICE_NAME = "My TV" + +DEVICES_TO_IDS = { + TV_DEVICE_NAME: TV_DEVICE_ID, +} + +IDS_TO_DEVICES = { + TV_DEVICE_ID: TV_DEVICE_NAME, +} + + +class FakeHarmonyClient: + """FakeHarmonyClient to mock away network calls.""" + + def __init__( + self, ip_address: str = "", callbacks: ClientCallbackType = MagicMock() + ): + """Initialize FakeHarmonyClient class.""" + self._activity_name = "Watch TV" + self.close = AsyncMock() + self.send_commands = AsyncMock() + self.change_channel = AsyncMock() + self.sync = AsyncMock() + self._callbacks = callbacks + + async def connect(self): + """Connect and call the appropriate callbacks.""" + self._callbacks.connect(None) + return AsyncMock(return_value=(True)) + + def get_activity_name(self, activity_id): + """Return the activity name with the given activity_id.""" + return IDS_TO_ACTIVITIES.get(activity_id) + + def get_activity_id(self, activity_name): + """Return the mapping of an activity name to the internal id.""" + return ACTIVITIES_TO_IDS.get(activity_name) + + def get_device_name(self, device_id): + """Return the device name with the given device_id.""" + return IDS_TO_DEVICES.get(device_id) + + def get_device_id(self, device_name): + """Return the device id with the given device_name.""" + return DEVICES_TO_IDS.get(device_name) + + async def start_activity(self, activity_id): + """Update the current activity and call the appropriate callbacks.""" + self._activity_name = IDS_TO_ACTIVITIES.get(int(activity_id)) + activity_tuple = (activity_id, self._activity_name) + self._callbacks.new_activity_starting(activity_tuple) + self._callbacks.new_activity(activity_tuple) + + return AsyncMock(return_value=(True, "unused message")) + + async def power_off(self): + """Power off all activities.""" + await self.start_activity(-1) + + @property + def current_activity(self): + """Return the current activity tuple.""" + return ( + self.get_activity_id(self._activity_name), + self._activity_name, + ) + + @property + def config(self): + """Return the config object.""" + return self.hub_config.config + + @property + def json_config(self): + """Return the json config as a dict.""" + return {} + + @property + def hub_config(self): + """Return the client_config type.""" + config = MagicMock() + type(config).activities = PropertyMock( + return_value=[ + {"name": "Watch TV", "id": WATCH_TV_ACTIVITY_ID}, + {"name": "Play Music", "id": PLAY_MUSIC_ACTIVITY_ID}, + ] + ) + type(config).devices = PropertyMock( + return_value=[{"name": TV_DEVICE_NAME, "id": TV_DEVICE_ID}] + ) + type(config).info = PropertyMock(return_value={}) + type(config).hub_state = PropertyMock(return_value={}) + type(config).config = PropertyMock( + return_value={ + "activity": [ + {"id": WATCH_TV_ACTIVITY_ID, "label": "Watch TV"}, + {"id": PLAY_MUSIC_ACTIVITY_ID, "label": "Play Music"}, + ] + } + ) + return config + + +@pytest.fixture() +def mock_hc(): + """Create a mock HarmonyClient.""" + with patch( + "homeassistant.components.harmony.data.HarmonyClient", + side_effect=FakeHarmonyClient, + ) as fake: + yield fake + + +@pytest.fixture() +def mock_write_config(): + """Patches write_config_file to remove side effects.""" + with patch( + "homeassistant.components.harmony.remote.HarmonyRemote.write_config_file", + ) as mock: + yield mock diff --git a/tests/components/harmony/const.py b/tests/components/harmony/const.py new file mode 100644 index 0000000000000000000000000000000000000000..1911ea949aff078c24adb2eb360aaefc830cf232 --- /dev/null +++ b/tests/components/harmony/const.py @@ -0,0 +1,6 @@ +"""Constants for Logitch Harmony Hub tests.""" + +HUB_NAME = "Guest Room" +ENTITY_REMOTE = "remote.guest_room" +ENTITY_WATCH_TV = "switch.guest_room_watch_tv" +ENTITY_PLAY_MUSIC = "switch.guest_room_play_music" diff --git a/tests/components/harmony/test_activity_changes.py b/tests/components/harmony/test_activity_changes.py new file mode 100644 index 0000000000000000000000000000000000000000..ff76c3ce998bacf211eace811cc8b59323c7f89b --- /dev/null +++ b/tests/components/harmony/test_activity_changes.py @@ -0,0 +1,137 @@ +"""Test the Logitech Harmony Hub activity switches.""" + +import logging + +from homeassistant.components.harmony.const import DOMAIN +from homeassistant.components.remote import ATTR_ACTIVITY, DOMAIN as REMOTE_DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_NAME, + STATE_OFF, + STATE_ON, +) + +from .conftest import ACTIVITIES_TO_IDS +from .const import ENTITY_PLAY_MUSIC, ENTITY_REMOTE, ENTITY_WATCH_TV, HUB_NAME + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +async def test_switch_toggles(mock_hc, hass, mock_write_config): + """Ensure calls to the switch modify the harmony state.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # mocks start with current activity == Watch TV + assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) + assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) + assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) + + # turn off watch tv switch + await _toggle_switch_and_wait(hass, SERVICE_TURN_OFF, ENTITY_WATCH_TV) + assert hass.states.is_state(ENTITY_REMOTE, STATE_OFF) + assert hass.states.is_state(ENTITY_WATCH_TV, STATE_OFF) + assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) + + # turn on play music switch + await _toggle_switch_and_wait(hass, SERVICE_TURN_ON, ENTITY_PLAY_MUSIC) + assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) + assert hass.states.is_state(ENTITY_WATCH_TV, STATE_OFF) + assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_ON) + + # turn on watch tv switch + await _toggle_switch_and_wait(hass, SERVICE_TURN_ON, ENTITY_WATCH_TV) + assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) + assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) + assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) + + +async def test_remote_toggles(mock_hc, hass, mock_write_config): + """Ensure calls to the remote also updates the switches.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # mocks start with current activity == Watch TV + assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) + assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) + assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) + + # turn off remote + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_REMOTE}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.is_state(ENTITY_REMOTE, STATE_OFF) + assert hass.states.is_state(ENTITY_WATCH_TV, STATE_OFF) + assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) + + # turn on remote, restoring the last activity + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_REMOTE}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) + assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) + assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) + + # send new activity command, with activity name + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_REMOTE, ATTR_ACTIVITY: "Play Music"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) + assert hass.states.is_state(ENTITY_WATCH_TV, STATE_OFF) + assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_ON) + + # send new activity command, with activity id + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_REMOTE, ATTR_ACTIVITY: ACTIVITIES_TO_IDS["Watch TV"]}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) + assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) + assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) + + +async def _toggle_switch_and_wait(hass, service_name, entity): + await hass.services.async_call( + SWITCH_DOMAIN, + service_name, + {ATTR_ENTITY_ID: entity}, + blocking=True, + ) + await hass.async_block_till_done() diff --git a/tests/components/harmony/test_commands.py b/tests/components/harmony/test_commands.py new file mode 100644 index 0000000000000000000000000000000000000000..62056a08e1dff207d6cb98916ca5c96c921d2870 --- /dev/null +++ b/tests/components/harmony/test_commands.py @@ -0,0 +1,263 @@ +"""Test sending commands to the Harmony Hub remote.""" + +from aioharmony.const import SendCommandDevice + +from homeassistant.components.harmony.const import ( + DOMAIN, + SERVICE_CHANGE_CHANNEL, + SERVICE_SYNC, +) +from homeassistant.components.harmony.remote import ATTR_CHANNEL, ATTR_DELAY_SECS +from homeassistant.components.remote import ( + ATTR_COMMAND, + ATTR_DEVICE, + ATTR_NUM_REPEATS, + DEFAULT_DELAY_SECS, + DEFAULT_HOLD_SECS, + DOMAIN as REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME + +from .conftest import TV_DEVICE_ID, TV_DEVICE_NAME +from .const import ENTITY_REMOTE, HUB_NAME + +from tests.common import MockConfigEntry + +PLAY_COMMAND = "Play" +STOP_COMMAND = "Stop" + + +async def test_async_send_command(mock_hc, hass, mock_write_config): + """Ensure calls to send remote commands properly propagate to devices.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + data = hass.data[DOMAIN][entry.entry_id] + send_commands_mock = data._client.send_commands + + # No device provided + await _send_commands_and_wait( + hass, {ATTR_ENTITY_ID: ENTITY_REMOTE, ATTR_COMMAND: PLAY_COMMAND} + ) + send_commands_mock.assert_not_awaited() + + # Tell the TV to play by id + await _send_commands_and_wait( + hass, + { + ATTR_ENTITY_ID: ENTITY_REMOTE, + ATTR_COMMAND: PLAY_COMMAND, + ATTR_DEVICE: TV_DEVICE_ID, + }, + ) + + send_commands_mock.assert_awaited_once_with( + [ + SendCommandDevice( + device=str(TV_DEVICE_ID), + command=PLAY_COMMAND, + delay=float(DEFAULT_HOLD_SECS), + ), + DEFAULT_DELAY_SECS, + ] + ) + send_commands_mock.reset_mock() + + # Tell the TV to play by name + await _send_commands_and_wait( + hass, + { + ATTR_ENTITY_ID: ENTITY_REMOTE, + ATTR_COMMAND: PLAY_COMMAND, + ATTR_DEVICE: TV_DEVICE_NAME, + }, + ) + + send_commands_mock.assert_awaited_once_with( + [ + SendCommandDevice( + device=TV_DEVICE_ID, + command=PLAY_COMMAND, + delay=float(DEFAULT_HOLD_SECS), + ), + DEFAULT_DELAY_SECS, + ] + ) + send_commands_mock.reset_mock() + + # Tell the TV to play and stop by name + await _send_commands_and_wait( + hass, + { + ATTR_ENTITY_ID: ENTITY_REMOTE, + ATTR_COMMAND: [PLAY_COMMAND, STOP_COMMAND], + ATTR_DEVICE: TV_DEVICE_NAME, + }, + ) + + send_commands_mock.assert_awaited_once_with( + [ + SendCommandDevice( + device=TV_DEVICE_ID, + command=PLAY_COMMAND, + delay=float(DEFAULT_HOLD_SECS), + ), + DEFAULT_DELAY_SECS, + SendCommandDevice( + device=TV_DEVICE_ID, + command=STOP_COMMAND, + delay=float(DEFAULT_HOLD_SECS), + ), + DEFAULT_DELAY_SECS, + ] + ) + send_commands_mock.reset_mock() + + # Tell the TV to play by name multiple times + await _send_commands_and_wait( + hass, + { + ATTR_ENTITY_ID: ENTITY_REMOTE, + ATTR_COMMAND: PLAY_COMMAND, + ATTR_DEVICE: TV_DEVICE_NAME, + ATTR_NUM_REPEATS: 2, + }, + ) + + send_commands_mock.assert_awaited_once_with( + [ + SendCommandDevice( + device=TV_DEVICE_ID, + command=PLAY_COMMAND, + delay=float(DEFAULT_HOLD_SECS), + ), + DEFAULT_DELAY_SECS, + SendCommandDevice( + device=TV_DEVICE_ID, + command=PLAY_COMMAND, + delay=float(DEFAULT_HOLD_SECS), + ), + DEFAULT_DELAY_SECS, + ] + ) + send_commands_mock.reset_mock() + + # Send commands to an unknown device + await _send_commands_and_wait( + hass, + { + ATTR_ENTITY_ID: ENTITY_REMOTE, + ATTR_COMMAND: PLAY_COMMAND, + ATTR_DEVICE: "no-such-device", + }, + ) + send_commands_mock.assert_not_awaited() + send_commands_mock.reset_mock() + + +async def test_async_send_command_custom_delay(mock_hc, hass, mock_write_config): + """Ensure calls to send remote commands properly propagate to devices with custom delays.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.0.2.0", + CONF_NAME: HUB_NAME, + ATTR_DELAY_SECS: DEFAULT_DELAY_SECS + 2, + }, + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + data = hass.data[DOMAIN][entry.entry_id] + send_commands_mock = data._client.send_commands + + # Tell the TV to play by id + await _send_commands_and_wait( + hass, + { + ATTR_ENTITY_ID: ENTITY_REMOTE, + ATTR_COMMAND: PLAY_COMMAND, + ATTR_DEVICE: TV_DEVICE_ID, + }, + ) + + send_commands_mock.assert_awaited_once_with( + [ + SendCommandDevice( + device=str(TV_DEVICE_ID), + command=PLAY_COMMAND, + delay=float(DEFAULT_HOLD_SECS), + ), + DEFAULT_DELAY_SECS + 2, + ] + ) + send_commands_mock.reset_mock() + + +async def test_change_channel(mock_hc, hass, mock_write_config): + """Test change channel commands.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + data = hass.data[DOMAIN][entry.entry_id] + change_channel_mock = data._client.change_channel + + # Tell the remote to change channels + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE_CHANNEL, + {ATTR_ENTITY_ID: ENTITY_REMOTE, ATTR_CHANNEL: 100}, + blocking=True, + ) + await hass.async_block_till_done() + + change_channel_mock.assert_awaited_once_with(100) + + +async def test_sync(mock_hc, mock_write_config, hass): + """Test the sync command.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + data = hass.data[DOMAIN][entry.entry_id] + sync_mock = data._client.sync + + # Tell the remote to change channels + await hass.services.async_call( + DOMAIN, + SERVICE_SYNC, + {ATTR_ENTITY_ID: ENTITY_REMOTE}, + blocking=True, + ) + await hass.async_block_till_done() + + sync_mock.assert_awaited_once() + mock_write_config.assert_called() + + +async def _send_commands_and_wait(hass, service_data): + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + service_data, + blocking=True, + ) + await hass.async_block_till_done() diff --git a/tests/components/harmony/test_config_flow.py b/tests/components/harmony/test_config_flow.py index f43c9f6b478db8ffa34e6a8027573c7ea5206cdf..e5d0c6f0570ace790f6a59a69b497d02417ff587 100644 --- a/tests/components/harmony/test_config_flow.py +++ b/tests/components/harmony/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Logitech Harmony Hub config flow.""" -from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.harmony.config_flow import CannotConnect @@ -17,23 +17,6 @@ def _get_mock_harmonyapi(connect=None, close=None): return harmonyapi_mock -def _get_mock_harmonyclient(): - harmonyclient_mock = MagicMock() - type(harmonyclient_mock).connect = AsyncMock() - type(harmonyclient_mock).close = AsyncMock() - type(harmonyclient_mock).get_activity_name = MagicMock(return_value="Watch TV") - type(harmonyclient_mock.hub_config).activities = PropertyMock( - return_value=[{"name": "Watch TV", "id": 123}] - ) - type(harmonyclient_mock.hub_config).devices = PropertyMock( - return_value=[{"name": "My TV", "id": 1234}] - ) - type(harmonyclient_mock.hub_config).info = PropertyMock(return_value={}) - type(harmonyclient_mock.hub_config).hub_state = PropertyMock(return_value={}) - - return harmonyclient_mock - - async def test_user_form(hass): """Test we get the user form.""" await setup.async_setup_component(hass, "persistent_notification", {}) @@ -213,9 +196,8 @@ async def test_form_cannot_connect(hass): assert result2["errors"] == {"base": "cannot_connect"} -async def test_options_flow(hass): +async def test_options_flow(hass, mock_hc): """Test config flow options.""" - config_entry = MockConfigEntry( domain=DOMAIN, unique_id="abcde12345", @@ -223,19 +205,13 @@ async def test_options_flow(hass): options={"activity": "Watch TV", "delay_secs": 0.5}, ) - harmony_client = _get_mock_harmonyclient() - - with patch( - "aioharmony.harmonyapi.HarmonyClient", - return_value=harmony_client, - ), patch("homeassistant.components.harmony.remote.HarmonyRemote.write_config_file"): - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(config_entry.entry_id) - await hass.async_block_till_done() - assert await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "init" diff --git a/tests/components/harmony/test_connection_changes.py b/tests/components/harmony/test_connection_changes.py new file mode 100644 index 0000000000000000000000000000000000000000..15d462988555a0095a87d43a45680aa4ef6fb054 --- /dev/null +++ b/tests/components/harmony/test_connection_changes.py @@ -0,0 +1,67 @@ +"""Test the Logitech Harmony Hub entities with connection state changes.""" + +from datetime import timedelta + +from homeassistant.components.harmony.const import DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.util import utcnow + +from .const import ENTITY_PLAY_MUSIC, ENTITY_REMOTE, ENTITY_WATCH_TV, HUB_NAME + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_connection_state_changes(mock_hc, hass, mock_write_config): + """Ensure connection changes are reflected in the switch states.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + data = hass.data[DOMAIN][entry.entry_id] + + # mocks start with current activity == Watch TV + assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) + assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) + assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) + + data._disconnected() + await hass.async_block_till_done() + + # Entities do not immediately show as unavailable + assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) + assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) + assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) + + future_time = utcnow() + timedelta(seconds=10) + async_fire_time_changed(hass, future_time) + await hass.async_block_till_done() + assert hass.states.is_state(ENTITY_REMOTE, STATE_UNAVAILABLE) + assert hass.states.is_state(ENTITY_WATCH_TV, STATE_UNAVAILABLE) + assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_UNAVAILABLE) + + data._connected() + await hass.async_block_till_done() + + assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) + assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) + assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) + + data._disconnected() + data._connected() + future_time = utcnow() + timedelta(seconds=10) + async_fire_time_changed(hass, future_time) + + await hass.async_block_till_done() + assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) + assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) + assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) diff --git a/tests/components/harmony/test_subscriber.py b/tests/components/harmony/test_subscriber.py new file mode 100644 index 0000000000000000000000000000000000000000..5c357bef82509d7ede2a0ea23787579d863563f7 --- /dev/null +++ b/tests/components/harmony/test_subscriber.py @@ -0,0 +1,143 @@ +"""Test the HarmonySubscriberMixin class.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.components.harmony.subscriber import ( + HarmonyCallback, + HarmonySubscriberMixin, +) + +_NO_PARAM_CALLBACKS = { + "connected": "_connected", + "disconnected": "_disconnected", + "config_updated": "_config_updated", +} + +_ACTIVITY_CALLBACKS = { + "activity_starting": "_activity_starting", + "activity_started": "_activity_started", +} + +_ALL_CALLBACK_NAMES = list(_NO_PARAM_CALLBACKS.keys()) + list( + _ACTIVITY_CALLBACKS.keys() +) + +_ACTIVITY_TUPLE = ("not", "used") + + +async def test_no_callbacks(hass): + """Ensure we handle no subscriptions.""" + subscriber = HarmonySubscriberMixin(hass) + _call_all_callbacks(subscriber) + await hass.async_block_till_done() + + +async def test_empty_callbacks(hass): + """Ensure we handle a missing callback in a subscription.""" + subscriber = HarmonySubscriberMixin(hass) + + callbacks = {k: None for k in _ALL_CALLBACK_NAMES} + subscriber.async_subscribe(HarmonyCallback(**callbacks)) + _call_all_callbacks(subscriber) + await hass.async_block_till_done() + + +async def test_async_callbacks(hass): + """Ensure we handle async callbacks.""" + subscriber = HarmonySubscriberMixin(hass) + + callbacks = {k: AsyncMock() for k in _ALL_CALLBACK_NAMES} + subscriber.async_subscribe(HarmonyCallback(**callbacks)) + _call_all_callbacks(subscriber) + await hass.async_block_till_done() + + for callback_name in _NO_PARAM_CALLBACKS.keys(): + callback_mock = callbacks[callback_name] + callback_mock.assert_awaited_once() + + for callback_name in _ACTIVITY_CALLBACKS.keys(): + callback_mock = callbacks[callback_name] + callback_mock.assert_awaited_once_with(_ACTIVITY_TUPLE) + + +async def test_long_async_callbacks(hass): + """Ensure we handle async callbacks that may have sleeps.""" + subscriber = HarmonySubscriberMixin(hass) + + blocker_event = asyncio.Event() + notifier_event_one = asyncio.Event() + notifier_event_two = asyncio.Event() + + async def blocks_until_notified(): + await blocker_event.wait() + notifier_event_one.set() + + async def notifies_when_called(): + notifier_event_two.set() + + callbacks_one = {k: blocks_until_notified for k in _ALL_CALLBACK_NAMES} + callbacks_two = {k: notifies_when_called for k in _ALL_CALLBACK_NAMES} + subscriber.async_subscribe(HarmonyCallback(**callbacks_one)) + subscriber.async_subscribe(HarmonyCallback(**callbacks_two)) + + subscriber._connected() + await notifier_event_two.wait() + blocker_event.set() + await notifier_event_one.wait() + + +async def test_callbacks(hass): + """Ensure we handle non-async callbacks.""" + subscriber = HarmonySubscriberMixin(hass) + + callbacks = {k: MagicMock() for k in _ALL_CALLBACK_NAMES} + subscriber.async_subscribe(HarmonyCallback(**callbacks)) + _call_all_callbacks(subscriber) + await hass.async_block_till_done() + + for callback_name in _NO_PARAM_CALLBACKS.keys(): + callback_mock = callbacks[callback_name] + callback_mock.assert_called_once() + + for callback_name in _ACTIVITY_CALLBACKS.keys(): + callback_mock = callbacks[callback_name] + callback_mock.assert_called_once_with(_ACTIVITY_TUPLE) + + +async def test_subscribe_unsubscribe(hass): + """Ensure we handle subscriptions and unsubscriptions correctly.""" + subscriber = HarmonySubscriberMixin(hass) + + callback_one = {k: MagicMock() for k in _ALL_CALLBACK_NAMES} + unsub_one = subscriber.async_subscribe(HarmonyCallback(**callback_one)) + callback_two = {k: MagicMock() for k in _ALL_CALLBACK_NAMES} + _ = subscriber.async_subscribe(HarmonyCallback(**callback_two)) + callback_three = {k: MagicMock() for k in _ALL_CALLBACK_NAMES} + unsub_three = subscriber.async_subscribe(HarmonyCallback(**callback_three)) + + unsub_one() + unsub_three() + + _call_all_callbacks(subscriber) + await hass.async_block_till_done() + + for callback_name in _NO_PARAM_CALLBACKS.keys(): + callback_one[callback_name].assert_not_called() + callback_two[callback_name].assert_called_once() + callback_three[callback_name].assert_not_called() + + for callback_name in _ACTIVITY_CALLBACKS.keys(): + callback_one[callback_name].assert_not_called() + callback_two[callback_name].assert_called_once_with(_ACTIVITY_TUPLE) + callback_three[callback_name].assert_not_called() + + +def _call_all_callbacks(subscriber): + for callback_method in _NO_PARAM_CALLBACKS.values(): + to_call = getattr(subscriber, callback_method) + to_call() + + for callback_method in _ACTIVITY_CALLBACKS.values(): + to_call = getattr(subscriber, callback_method) + to_call(_ACTIVITY_TUPLE)