497 lines
17 KiB
Python
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,
|
|
)
|