core/homeassistant/components/harmony/remote.py

497 lines
17 KiB
Python

"""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
from homeassistant.components.remote import (
ATTR_ACTIVITY,
ATTR_DELAY_SECS,
ATTR_DEVICE,
ATTR_HOLD_SECS,
ATTR_NUM_REPEATS,
DEFAULT_DELAY_SECS,
PLATFORM_SCHEMA,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import entity_platform
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.restore_state import RestoreEntity
from .const import (
ACTIVITY_POWER_OFF,
ATTR_ACTIVITY_LIST,
ATTR_ACTIVITY_STARTING,
ATTR_CURRENT_ACTIVITY,
ATTR_DEVICES_LIST,
ATTR_LAST_ACTIVITY,
DOMAIN,
HARMONY_OPTIONS_UPDATE,
PREVIOUS_ACTIVE_ACTIVITY,
SERVICE_CHANGE_CHANNEL,
SERVICE_SYNC,
UNIQUE_ID,
)
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__)
# We want to fire remote commands right away
PARALLEL_UPDATES = 0
ATTR_CHANNEL = "channel"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Optional(ATTR_ACTIVITY): cv.string,
vol.Required(CONF_NAME): cv.string,
vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): vol.Coerce(float),
vol.Required(CONF_HOST): cv.string,
# The client ignores port so lets not confuse the user by pretenting we do anything with this
},
extra=vol.ALLOW_EXTRA,
)
HARMONY_SYNC_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
HARMONY_CHANGE_CHANNEL_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
vol.Required(ATTR_CHANNEL): cv.positive_int,
}
)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Harmony platform."""
if discovery_info:
# Now handled by ssdp in the config flow
return
if find_matching_config_entries_for_host(hass, config[CONF_HOST]):
return
# We do the validation to verify we can connect
# so we can raise PlatformNotReady to force
# a retry so we can avoid a scenario where the config
# entry cannot be created via import because hub
# is not yet ready.
harmony = await get_harmony_client_if_available(config[CONF_HOST])
if not harmony:
raise PlatformNotReady
validated_config = config.copy()
validated_config[UNIQUE_ID] = find_unique_id_for_remote(harmony)
validated_config[CONF_NAME] = find_best_name_for_remote(config, harmony)
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=validated_config
)
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
):
"""Set up the Harmony config entry."""
device = hass.data[DOMAIN][entry.entry_id]
_LOGGER.debug("Harmony Remote: %s", device)
async_add_entities([device])
platform = entity_platform.current_platform.get()
platform.async_register_entity_service(
SERVICE_SYNC, HARMONY_SYNC_SCHEMA, "sync",
)
platform.async_register_entity_service(
SERVICE_CHANGE_CHANNEL, HARMONY_CHANGE_CHANNEL_SCHEMA, "change_channel"
)
class HarmonyRemote(remote.RemoteEntity, RestoreEntity):
"""Remote representation used to control a Harmony device."""
def __init__(self, name, unique_id, host, activity, out_path, delay_secs):
"""Initialize HarmonyRemote class."""
self._name = name
self.host = host
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._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
async def _async_update_options(self, data):
"""Change options when the options flow does."""
if ATTR_DELAY_SECS in data:
self.delay_secs = data[ATTR_DELAY_SECS]
if ATTR_ACTIVITY in data:
self.default_activity = data[ATTR_ACTIVITY]
def _update_callbacks(self):
callbacks = {
"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,
}
self._client.callbacks = ClientCallbackType(**callbacks)
def _new_activity_finished(self, activity_info: tuple) -> None:
"""Call for finished updated current activity."""
self._activity_starting = None
self.async_write_ha_state()
async def async_added_to_hass(self):
"""Complete the initialization."""
await super().async_added_to_hass()
_LOGGER.debug("%s: Harmony Hub added", self._name)
# Register the callbacks
self._update_callbacks()
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{HARMONY_OPTIONS_UPDATE}-{self.unique_id}",
self._async_update_options,
)
)
# Store Harmony HUB config, this will also update our current
# activity
await self.new_config()
# Restore the last activity so we know
# how what to turn on if nothing
# is specified
last_state = await self.async_get_last_state()
if not last_state:
return
if ATTR_LAST_ACTIVITY not in last_state.attributes:
return
if self.is_on:
return
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,
}
@property
def unique_id(self):
"""Return the unique id."""
return self._unique_id
@property
def name(self):
"""Return the Harmony device's name."""
return self._name
@property
def should_poll(self):
"""Return the fact that we should not be polled."""
return False
@property
def device_state_attributes(self):
"""Add platform specific attributes."""
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_LAST_ACTIVITY: self._last_activity,
}
@property
def is_on(self):
"""Return False if PowerOff is the current activity, otherwise True."""
return self._current_activity not in [None, "PowerOff"]
@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
def new_activity(self, activity_info: tuple) -> None:
"""Call for updating the current activity."""
activity_id, activity_name = activity_info
_LOGGER.debug("%s: activity reported as: %s", self._name, activity_name)
self._current_activity = activity_name
if self._is_initial_update:
self._is_initial_update = False
else:
self._activity_starting = activity_name
if activity_id != -1:
# Save the activity so we can restore
# to that activity if none is specified
# 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)
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)
activity = kwargs.get(ATTR_ACTIVITY, self.default_activity)
if not activity or activity == PREVIOUS_ACTIVE_ACTIVITY:
if self._last_activity:
activity = self._last_activity
else:
all_activities = list_names_from_hublist(
self._client.hub_config.activities
)
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)
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)
async def async_send_command(self, command, **kwargs):
"""Send a list of commands to one device."""
_LOGGER.debug("%s: Send Command", self.name)
device = kwargs.get(ATTR_DEVICE)
if device is None:
_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,
)
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)
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:
await self.hass.async_add_executor_job(self.write_config_file)
def write_config_file(self):
"""Write Harmony configuration file."""
_LOGGER.debug(
"%s: Writing hub configuration to file: %s", self.name, self._config_path
)
if self._client.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)
except OSError as exc:
_LOGGER.error(
"%s: Unable to write HUB configuration to %s: %s",
self.name,
self._config_path,
exc,
)