Generate switches for harmony activities automatically (#42331)

* Adding switch code for harmony activities

* Working on-off

* Removing poll code for now

* Async updates for current activity

* Update our state based on events

* Notifications we got connected or disconnected

* Remove unncessary constructor arg

* Initial switch tests

* Additional tests for switch transitions

* Test transitions for availability

* Testing switch state changes

* Tests passing

* Final tests

* Updating manifest.

* Correctly mock the return value from a call to the library

* Adding new subscriber classes

* Update class name and location

* Got the refactor working locally.

* Tests passing

* Tracking state changes

* Remove write_to_config_file - this appears to never be read.

It was added far back in the past to account for a harmony library
change, but nothing ever reads that path.

Removing that side effect from tests is a pain - avoid the side effect
completely.

* Connection changes tested

* Clean up temporary code

* Update .coveragerc for harmony component

Specifically exclude untested files instead of the whole module

* Fix linting

* test sending activity change commands by id

* Improving coverage

* Testing channel change commands

* Splitting subscriber logic into it's own class

* Improve coverage and tighten up .coveragerc

* Test cleanups.

* re-add config file writing for harmony remote

* Create fixture for the mock harmonyclient

* Reduce duplication in subscription callbacks

* use async_run_job to call callbacks

* Adding some tests for async behaviors with subscribers.

* async_call_later for delay in marking remote unavailable

* Test disconnection handling in harmony remote

* Early exit if activity not specified

* Use connection state mixin

* Lint fix after rebase

* Fix isort

* super init for ConnectionStateMixin

* Adding @mkeesey to harmony CODEOWNERS
This commit is contained in:
Mike Keesey 2021-01-04 16:21:14 -07:00 committed by GitHub
parent 2e50c1be8e
commit 60a1948ab0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1291 additions and 252 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
)
# 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,
await self._data.async_send_command(
command, device, num_repeats, delay_secs, hold_secs
)
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",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,12 +205,6 @@ 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()

View file

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

View file

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