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:
parent
2e50c1be8e
commit
60a1948ab0
17 changed files with 1291 additions and 252 deletions
|
@ -358,7 +358,10 @@ omit =
|
||||||
homeassistant/components/hangouts/hangouts_bot.py
|
homeassistant/components/hangouts/hangouts_bot.py
|
||||||
homeassistant/components/hangouts/hangups_utils.py
|
homeassistant/components/hangouts/hangups_utils.py
|
||||||
homeassistant/components/harman_kardon_avr/media_player.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/haveibeenpwned/sensor.py
|
||||||
homeassistant/components/hdmi_cec/*
|
homeassistant/components/hdmi_cec/*
|
||||||
homeassistant/components/heatmiser/climate.py
|
homeassistant/components/heatmiser/climate.py
|
||||||
|
|
|
@ -180,7 +180,7 @@ homeassistant/components/griddy/* @bdraco
|
||||||
homeassistant/components/group/* @home-assistant/core
|
homeassistant/components/group/* @home-assistant/core
|
||||||
homeassistant/components/growatt_server/* @indykoning
|
homeassistant/components/growatt_server/* @indykoning
|
||||||
homeassistant/components/guardian/* @bachya
|
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/hassio/* @home-assistant/supervisor
|
||||||
homeassistant/components/hdmi_cec/* @newAM
|
homeassistant/components/hdmi_cec/* @newAM
|
||||||
homeassistant/components/heatmiser/* @andylockran
|
homeassistant/components/heatmiser/* @andylockran
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
"""The Logitech Harmony Hub integration."""
|
"""The Logitech Harmony Hub integration."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from homeassistant.components.remote import (
|
from homeassistant.components.remote import ATTR_ACTIVITY, ATTR_DELAY_SECS
|
||||||
ATTR_ACTIVITY,
|
|
||||||
ATTR_DELAY_SECS,
|
|
||||||
DEFAULT_DELAY_SECS,
|
|
||||||
)
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_HOST, CONF_NAME
|
from homeassistant.const import CONF_HOST, CONF_NAME
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
@ -13,7 +9,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
|
|
||||||
from .const import DOMAIN, HARMONY_OPTIONS_UPDATE, PLATFORMS
|
from .const import DOMAIN, HARMONY_OPTIONS_UPDATE, PLATFORMS
|
||||||
from .remote import HarmonyRemote
|
from .data import HarmonyData
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: dict):
|
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]
|
address = entry.data[CONF_HOST]
|
||||||
name = entry.data[CONF_NAME]
|
name = entry.data[CONF_NAME]
|
||||||
activity = entry.options.get(ATTR_ACTIVITY)
|
data = HarmonyData(hass, address, name, entry.unique_id)
|
||||||
delay_secs = entry.options.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS)
|
|
||||||
|
|
||||||
harmony_conf_file = hass.config.path(f"harmony_{entry.unique_id}.conf")
|
|
||||||
try:
|
try:
|
||||||
device = HarmonyRemote(
|
connected_ok = await data.connect()
|
||||||
name, entry.unique_id, address, activity, harmony_conf_file, delay_secs
|
|
||||||
)
|
|
||||||
connected_ok = await device.connect()
|
|
||||||
except (asyncio.TimeoutError, ValueError, AttributeError) as err:
|
except (asyncio.TimeoutError, ValueError, AttributeError) as err:
|
||||||
raise ConfigEntryNotReady from err
|
raise ConfigEntryNotReady from err
|
||||||
|
|
||||||
if not connected_ok:
|
if not connected_ok:
|
||||||
raise ConfigEntryNotReady
|
raise ConfigEntryNotReady
|
||||||
|
|
||||||
hass.data[DOMAIN][entry.entry_id] = device
|
hass.data[DOMAIN][entry.entry_id] = data
|
||||||
|
|
||||||
entry.add_update_listener(_update_listener)
|
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
|
# Shutdown a harmony remote for removal
|
||||||
device = hass.data[DOMAIN][entry.entry_id]
|
data = hass.data[DOMAIN][entry.entry_id]
|
||||||
await device.shutdown()
|
await data.shutdown()
|
||||||
|
|
||||||
if unload_ok:
|
if unload_ok:
|
||||||
hass.data[DOMAIN].pop(entry.entry_id)
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
|
44
homeassistant/components/harmony/connection_state.py
Normal file
44
homeassistant/components/harmony/connection_state.py
Normal 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()
|
|
@ -2,7 +2,7 @@
|
||||||
DOMAIN = "harmony"
|
DOMAIN = "harmony"
|
||||||
SERVICE_SYNC = "sync"
|
SERVICE_SYNC = "sync"
|
||||||
SERVICE_CHANGE_CHANNEL = "change_channel"
|
SERVICE_CHANGE_CHANNEL = "change_channel"
|
||||||
PLATFORMS = ["remote"]
|
PLATFORMS = ["remote", "switch"]
|
||||||
UNIQUE_ID = "unique_id"
|
UNIQUE_ID = "unique_id"
|
||||||
ACTIVITY_POWER_OFF = "PowerOff"
|
ACTIVITY_POWER_OFF = "PowerOff"
|
||||||
HARMONY_OPTIONS_UPDATE = "harmony_options_update"
|
HARMONY_OPTIONS_UPDATE = "harmony_options_update"
|
||||||
|
|
251
homeassistant/components/harmony/data.py
Normal file
251
homeassistant/components/harmony/data.py
Normal 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
|
|
@ -3,12 +3,13 @@
|
||||||
"name": "Logitech Harmony Hub",
|
"name": "Logitech Harmony Hub",
|
||||||
"documentation": "https://www.home-assistant.io/integrations/harmony",
|
"documentation": "https://www.home-assistant.io/integrations/harmony",
|
||||||
"requirements": ["aioharmony==0.2.6"],
|
"requirements": ["aioharmony==0.2.6"],
|
||||||
"codeowners": ["@ehendrix23", "@bramkragten", "@bdraco"],
|
"codeowners": ["@ehendrix23", "@bramkragten", "@bdraco", "@mkeesey"],
|
||||||
"ssdp": [
|
"ssdp": [
|
||||||
{
|
{
|
||||||
"manufacturer": "Logitech",
|
"manufacturer": "Logitech",
|
||||||
"deviceType": "urn:myharmony-com:device:harmony:1"
|
"deviceType": "urn:myharmony-com:device:harmony:1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"dependencies": ["remote", "switch"],
|
||||||
"config_flow": true
|
"config_flow": true
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
"""Support for Harmony Hub devices."""
|
"""Support for Harmony Hub devices."""
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from aioharmony.const import ClientCallbackType
|
|
||||||
import aioharmony.exceptions as aioexc
|
|
||||||
from aioharmony.harmonyapi import HarmonyAPI as HarmonyClient, SendCommandDevice
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components import remote
|
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.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.helpers.restore_state import RestoreEntity
|
from homeassistant.helpers.restore_state import RestoreEntity
|
||||||
|
|
||||||
|
from .connection_state import ConnectionStateMixin
|
||||||
from .const import (
|
from .const import (
|
||||||
ACTIVITY_POWER_OFF,
|
ACTIVITY_POWER_OFF,
|
||||||
ATTR_ACTIVITY_LIST,
|
ATTR_ACTIVITY_LIST,
|
||||||
|
@ -41,12 +38,12 @@ from .const import (
|
||||||
SERVICE_SYNC,
|
SERVICE_SYNC,
|
||||||
UNIQUE_ID,
|
UNIQUE_ID,
|
||||||
)
|
)
|
||||||
|
from .subscriber import HarmonyCallback
|
||||||
from .util import (
|
from .util import (
|
||||||
find_best_name_for_remote,
|
find_best_name_for_remote,
|
||||||
find_matching_config_entries_for_host,
|
find_matching_config_entries_for_host,
|
||||||
find_unique_id_for_remote,
|
find_unique_id_for_remote,
|
||||||
get_harmony_client_if_available,
|
get_harmony_client_if_available,
|
||||||
list_names_from_hublist,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
@ -113,10 +110,15 @@ async def async_setup_entry(
|
||||||
):
|
):
|
||||||
"""Set up the Harmony config 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])
|
async_add_entities([device])
|
||||||
|
|
||||||
platform = entity_platform.current_platform.get()
|
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."""
|
"""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."""
|
"""Initialize HarmonyRemote class."""
|
||||||
self._name = name
|
super().__init__()
|
||||||
self.host = host
|
self._data = data
|
||||||
|
self._name = data.name
|
||||||
self._state = None
|
self._state = None
|
||||||
self._current_activity = ACTIVITY_POWER_OFF
|
self._current_activity = ACTIVITY_POWER_OFF
|
||||||
self.default_activity = activity
|
self.default_activity = activity
|
||||||
self._activity_starting = None
|
self._activity_starting = None
|
||||||
self._is_initial_update = True
|
self._is_initial_update = True
|
||||||
self._client = HarmonyClient(ip_address=host)
|
|
||||||
self._config_path = out_path
|
|
||||||
self.delay_secs = delay_secs
|
self.delay_secs = delay_secs
|
||||||
self._available = False
|
self._unique_id = data.unique_id
|
||||||
self._unique_id = unique_id
|
|
||||||
self._last_activity = None
|
self._last_activity = None
|
||||||
|
self._config_path = out_path
|
||||||
@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
|
|
||||||
|
|
||||||
async def _async_update_options(self, data):
|
async def _async_update_options(self, data):
|
||||||
"""Change options when the options flow does."""
|
"""Change options when the options flow does."""
|
||||||
|
@ -171,15 +159,16 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity):
|
||||||
if ATTR_ACTIVITY in data:
|
if ATTR_ACTIVITY in data:
|
||||||
self.default_activity = data[ATTR_ACTIVITY]
|
self.default_activity = data[ATTR_ACTIVITY]
|
||||||
|
|
||||||
def _update_callbacks(self):
|
def _setup_callbacks(self):
|
||||||
callbacks = {
|
callbacks = {
|
||||||
|
"connected": self.got_connected,
|
||||||
|
"disconnected": self.got_disconnected,
|
||||||
"config_updated": self.new_config,
|
"config_updated": self.new_config,
|
||||||
"connect": self.got_connected,
|
"activity_starting": self.new_activity,
|
||||||
"disconnect": self.got_disconnected,
|
"activity_started": self._new_activity_finished,
|
||||||
"new_activity_starting": self.new_activity,
|
|
||||||
"new_activity": 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:
|
def _new_activity_finished(self, activity_info: tuple) -> None:
|
||||||
"""Call for finished updated current activity."""
|
"""Call for finished updated current activity."""
|
||||||
|
@ -191,8 +180,9 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity):
|
||||||
await super().async_added_to_hass()
|
await super().async_added_to_hass()
|
||||||
|
|
||||||
_LOGGER.debug("%s: Harmony Hub added", self._name)
|
_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(
|
self.async_on_remove(
|
||||||
async_dispatcher_connect(
|
async_dispatcher_connect(
|
||||||
|
@ -219,29 +209,10 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity):
|
||||||
|
|
||||||
self._last_activity = last_state.attributes[ATTR_LAST_ACTIVITY]
|
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
|
@property
|
||||||
def device_info(self):
|
def device_info(self):
|
||||||
"""Return device info."""
|
"""Return device info."""
|
||||||
model = "Harmony Hub"
|
self._data.device_info(DOMAIN)
|
||||||
if "ethernetStatus" in self._client.hub_config.info:
|
|
||||||
model = "Harmony Hub Pro 2400"
|
|
||||||
return {
|
|
||||||
"identifiers": {(DOMAIN, self.unique_id)},
|
|
||||||
"manufacturer": "Logitech",
|
|
||||||
"sw_version": self._client.hub_config.info.get(
|
|
||||||
"hubSwVersion", self._client.fw_version
|
|
||||||
),
|
|
||||||
"name": self.name,
|
|
||||||
"model": model,
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def unique_id(self):
|
def unique_id(self):
|
||||||
|
@ -264,10 +235,8 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity):
|
||||||
return {
|
return {
|
||||||
ATTR_ACTIVITY_STARTING: self._activity_starting,
|
ATTR_ACTIVITY_STARTING: self._activity_starting,
|
||||||
ATTR_CURRENT_ACTIVITY: self._current_activity,
|
ATTR_CURRENT_ACTIVITY: self._current_activity,
|
||||||
ATTR_ACTIVITY_LIST: list_names_from_hublist(
|
ATTR_ACTIVITY_LIST: self._data.activity_names,
|
||||||
self._client.hub_config.activities
|
ATTR_DEVICES_LIST: self._data.device_names,
|
||||||
),
|
|
||||||
ATTR_DEVICES_LIST: list_names_from_hublist(self._client.hub_config.devices),
|
|
||||||
ATTR_LAST_ACTIVITY: self._last_activity,
|
ATTR_LAST_ACTIVITY: self._last_activity,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -279,20 +248,7 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity):
|
||||||
@property
|
@property
|
||||||
def available(self):
|
def available(self):
|
||||||
"""Return True if connected to Hub, otherwise False."""
|
"""Return True if connected to Hub, otherwise False."""
|
||||||
return self._available
|
return self._data.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
|
|
||||||
|
|
||||||
def new_activity(self, activity_info: tuple) -> None:
|
def new_activity(self, activity_info: tuple) -> None:
|
||||||
"""Call for updating the current activity."""
|
"""Call for updating the current activity."""
|
||||||
|
@ -309,34 +265,14 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity):
|
||||||
# when turning on
|
# when turning on
|
||||||
self._last_activity = activity_name
|
self._last_activity = activity_name
|
||||||
self._state = bool(activity_id != -1)
|
self._state = bool(activity_id != -1)
|
||||||
self._available = True
|
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
async def new_config(self, _=None):
|
async def new_config(self, _=None):
|
||||||
"""Call for updating the current activity."""
|
"""Call for updating the current activity."""
|
||||||
_LOGGER.debug("%s: configuration has been updated", self._name)
|
_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)
|
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):
|
async def async_turn_on(self, **kwargs):
|
||||||
"""Start an activity from the Harmony device."""
|
"""Start an activity from the Harmony device."""
|
||||||
_LOGGER.debug("%s: Turn On", self.name)
|
_LOGGER.debug("%s: Turn On", self.name)
|
||||||
|
@ -347,55 +283,18 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity):
|
||||||
if self._last_activity:
|
if self._last_activity:
|
||||||
activity = self._last_activity
|
activity = self._last_activity
|
||||||
else:
|
else:
|
||||||
all_activities = list_names_from_hublist(
|
all_activities = self._data.activity_names
|
||||||
self._client.hub_config.activities
|
|
||||||
)
|
|
||||||
if all_activities:
|
if all_activities:
|
||||||
activity = all_activities[0]
|
activity = all_activities[0]
|
||||||
|
|
||||||
if activity:
|
if activity:
|
||||||
activity_id = None
|
await self._data.async_start_activity(activity)
|
||||||
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)
|
|
||||||
else:
|
else:
|
||||||
_LOGGER.error("%s: No activity specified with turn_on service", self.name)
|
_LOGGER.error("%s: No activity specified with turn_on service", self.name)
|
||||||
|
|
||||||
async def async_turn_off(self, **kwargs):
|
async def async_turn_off(self, **kwargs):
|
||||||
"""Start the PowerOff activity."""
|
"""Start the PowerOff activity."""
|
||||||
_LOGGER.debug("%s: Turn Off", self.name)
|
await self._data.async_power_off()
|
||||||
try:
|
|
||||||
await self._client.power_off()
|
|
||||||
except aioexc.TimeOut:
|
|
||||||
_LOGGER.error("%s: Powering off timed-out", self.name)
|
|
||||||
|
|
||||||
async def async_send_command(self, command, **kwargs):
|
async def async_send_command(self, command, **kwargs):
|
||||||
"""Send a list of commands to one device."""
|
"""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)
|
_LOGGER.error("%s: Missing required argument: device", self.name)
|
||||||
return
|
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]
|
num_repeats = kwargs[ATTR_NUM_REPEATS]
|
||||||
delay_secs = kwargs.get(ATTR_DELAY_SECS, self.delay_secs)
|
delay_secs = kwargs.get(ATTR_DELAY_SECS, self.delay_secs)
|
||||||
hold_secs = kwargs[ATTR_HOLD_SECS]
|
hold_secs = kwargs[ATTR_HOLD_SECS]
|
||||||
_LOGGER.debug(
|
await self._data.async_send_command(
|
||||||
"Sending commands to device %s holding for %s seconds "
|
command, device, num_repeats, delay_secs, hold_secs
|
||||||
"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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def change_channel(self, channel):
|
async def change_channel(self, channel):
|
||||||
"""Change the channel using Harmony remote."""
|
"""Change the channel using Harmony remote."""
|
||||||
_LOGGER.debug("%s: Changing channel to %s", self.name, channel)
|
await self._data.change_channel(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):
|
async def sync(self):
|
||||||
"""Sync the Harmony device with the web service."""
|
"""Sync the Harmony device with the web service."""
|
||||||
_LOGGER.debug("%s: Syncing hub with Harmony cloud", self.name)
|
if await self._data.sync():
|
||||||
try:
|
|
||||||
await self._client.sync()
|
|
||||||
except aioexc.TimeOut:
|
|
||||||
_LOGGER.error("%s: Syncing hub with Harmony cloud timed-out", self.name)
|
|
||||||
else:
|
|
||||||
await self.hass.async_add_executor_job(self.write_config_file)
|
await self.hass.async_add_executor_job(self.write_config_file)
|
||||||
|
|
||||||
def write_config_file(self):
|
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(
|
_LOGGER.debug(
|
||||||
"%s: Writing hub configuration to file: %s", self.name, self._config_path
|
"%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)
|
_LOGGER.warning("%s: No configuration received from hub", self.name)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(self._config_path, "w+", encoding="utf-8") as file_out:
|
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:
|
except OSError as exc:
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"%s: Unable to write HUB configuration to %s: %s",
|
"%s: Unable to write HUB configuration to %s: %s",
|
||||||
|
|
77
homeassistant/components/harmony/subscriber.py
Normal file
77
homeassistant/components/harmony/subscriber.py
Normal 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)
|
87
homeassistant/components/harmony/switch.py
Normal file
87
homeassistant/components/harmony/switch.py
Normal 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()
|
147
tests/components/harmony/conftest.py
Normal file
147
tests/components/harmony/conftest.py
Normal 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
|
6
tests/components/harmony/const.py
Normal file
6
tests/components/harmony/const.py
Normal 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"
|
137
tests/components/harmony/test_activity_changes.py
Normal file
137
tests/components/harmony/test_activity_changes.py
Normal 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()
|
263
tests/components/harmony/test_commands.py
Normal file
263
tests/components/harmony/test_commands.py
Normal 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()
|
|
@ -1,5 +1,5 @@
|
||||||
"""Test the Logitech Harmony Hub config flow."""
|
"""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 import config_entries, data_entry_flow, setup
|
||||||
from homeassistant.components.harmony.config_flow import CannotConnect
|
from homeassistant.components.harmony.config_flow import CannotConnect
|
||||||
|
@ -17,23 +17,6 @@ def _get_mock_harmonyapi(connect=None, close=None):
|
||||||
return harmonyapi_mock
|
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):
|
async def test_user_form(hass):
|
||||||
"""Test we get the user form."""
|
"""Test we get the user form."""
|
||||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
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"}
|
assert result2["errors"] == {"base": "cannot_connect"}
|
||||||
|
|
||||||
|
|
||||||
async def test_options_flow(hass):
|
async def test_options_flow(hass, mock_hc):
|
||||||
"""Test config flow options."""
|
"""Test config flow options."""
|
||||||
|
|
||||||
config_entry = MockConfigEntry(
|
config_entry = MockConfigEntry(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
unique_id="abcde12345",
|
unique_id="abcde12345",
|
||||||
|
@ -223,12 +205,6 @@ async def test_options_flow(hass):
|
||||||
options={"activity": "Watch TV", "delay_secs": 0.5},
|
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)
|
config_entry.add_to_hass(hass)
|
||||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
67
tests/components/harmony/test_connection_changes.py
Normal file
67
tests/components/harmony/test_connection_changes.py
Normal 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)
|
143
tests/components/harmony/test_subscriber.py
Normal file
143
tests/components/harmony/test_subscriber.py
Normal 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)
|
Loading…
Add table
Add a link
Reference in a new issue