Config flow for harmony (#32919)
* Config flow for harmony * Fixes unique ids when using XMPP Co-authored-by: Bram Kragten <mail@bramkragten.nl> * Find the unique id for the config flow * move shutdown to init * Add test for ssdp (still failing) * Fix ssdp test * Add harmony to MIGRATED_SERVICE_HANDLERS (this is a breaking change) * more cleanups * use unique id for the config file Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
parent
5b4d2aed64
commit
d33a3ca90f
16 changed files with 656 additions and 82 deletions
|
@ -147,7 +147,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/gtfs/* @robbiet480
|
homeassistant/components/gtfs/* @robbiet480
|
||||||
homeassistant/components/harmony/* @ehendrix23
|
homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco
|
||||||
homeassistant/components/hassio/* @home-assistant/hass-io
|
homeassistant/components/hassio/* @home-assistant/hass-io
|
||||||
homeassistant/components/heatmiser/* @andylockran
|
homeassistant/components/heatmiser/* @andylockran
|
||||||
homeassistant/components/heos/* @andrewsayre
|
homeassistant/components/heos/* @andrewsayre
|
||||||
|
|
|
@ -75,7 +75,6 @@ SERVICE_HANDLERS = {
|
||||||
"denonavr": ("media_player", "denonavr"),
|
"denonavr": ("media_player", "denonavr"),
|
||||||
"frontier_silicon": ("media_player", "frontier_silicon"),
|
"frontier_silicon": ("media_player", "frontier_silicon"),
|
||||||
"openhome": ("media_player", "openhome"),
|
"openhome": ("media_player", "openhome"),
|
||||||
"harmony": ("remote", "harmony"),
|
|
||||||
"bose_soundtouch": ("media_player", "soundtouch"),
|
"bose_soundtouch": ("media_player", "soundtouch"),
|
||||||
"bluesound": ("media_player", "bluesound"),
|
"bluesound": ("media_player", "bluesound"),
|
||||||
"songpal": ("media_player", "songpal"),
|
"songpal": ("media_player", "songpal"),
|
||||||
|
@ -93,6 +92,7 @@ MIGRATED_SERVICE_HANDLERS = [
|
||||||
"esphome",
|
"esphome",
|
||||||
"google_cast",
|
"google_cast",
|
||||||
SERVICE_HEOS,
|
SERVICE_HEOS,
|
||||||
|
"harmony",
|
||||||
"homekit",
|
"homekit",
|
||||||
"ikea_tradfri",
|
"ikea_tradfri",
|
||||||
"philips_hue",
|
"philips_hue",
|
||||||
|
|
38
homeassistant/components/harmony/.translations/en.json
Normal file
38
homeassistant/components/harmony/.translations/en.json
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"title": "Logitech Harmony Hub",
|
||||||
|
"flow_title": "Logitech Harmony Hub {name}",
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Setup Logitech Harmony Hub",
|
||||||
|
"data": {
|
||||||
|
"host": "Hostname or IP Address",
|
||||||
|
"name": "Hub Name"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"title": "Setup Logitech Harmony Hub",
|
||||||
|
"description": "Do you want to setup {name} ({host})?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Failed to connect, please try again",
|
||||||
|
"invalid_auth": "Invalid authentication",
|
||||||
|
"unknown": "Unexpected error"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Device is already configured"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"description": "Adjust Harmony Hub Options",
|
||||||
|
"data": {
|
||||||
|
"activity": "The default activity to execute when none is specified.",
|
||||||
|
"delay_secs": "The delay between sending commands."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1 +1,91 @@
|
||||||
"""Support for Harmony devices."""
|
"""The Logitech Harmony Hub integration."""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.components.remote import ATTR_ACTIVITY, ATTR_DELAY_SECS
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_NAME
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
|
||||||
|
from .const import DOMAIN, PLATFORMS
|
||||||
|
from .remote import DEVICES, HarmonyRemote
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass: HomeAssistant, config: dict):
|
||||||
|
"""Set up the Logitech Harmony Hub component."""
|
||||||
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
|
"""Set up Logitech Harmony Hub from a config entry."""
|
||||||
|
|
||||||
|
conf = entry.data
|
||||||
|
address = conf[CONF_HOST]
|
||||||
|
name = conf.get(CONF_NAME)
|
||||||
|
activity = conf.get(ATTR_ACTIVITY)
|
||||||
|
delay_secs = conf.get(ATTR_DELAY_SECS)
|
||||||
|
|
||||||
|
_LOGGER.info(
|
||||||
|
"Loading Harmony Platform: %s at %s, startup activity: %s",
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
activity,
|
||||||
|
)
|
||||||
|
|
||||||
|
harmony_conf_file = hass.config.path(f"harmony_{entry.unique_id}.conf")
|
||||||
|
try:
|
||||||
|
device = HarmonyRemote(name, address, activity, harmony_conf_file, delay_secs)
|
||||||
|
await device.connect()
|
||||||
|
except (asyncio.TimeoutError, ValueError, AttributeError):
|
||||||
|
raise ConfigEntryNotReady
|
||||||
|
|
||||||
|
hass.data[DOMAIN][entry.entry_id] = device
|
||||||
|
DEVICES.append(device)
|
||||||
|
|
||||||
|
entry.add_update_listener(_update_listener)
|
||||||
|
|
||||||
|
for component in PLATFORMS:
|
||||||
|
hass.async_create_task(
|
||||||
|
hass.config_entries.async_forward_entry_setup(entry, component)
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_listener(hass, entry):
|
||||||
|
"""Handle options update."""
|
||||||
|
|
||||||
|
device = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
|
||||||
|
if ATTR_DELAY_SECS in entry.options:
|
||||||
|
device.delay_seconds = entry.options[ATTR_DELAY_SECS]
|
||||||
|
|
||||||
|
if ATTR_ACTIVITY in entry.options:
|
||||||
|
device.default_activity = entry.options[ATTR_ACTIVITY]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
|
"""Unload a config entry."""
|
||||||
|
unload_ok = all(
|
||||||
|
await asyncio.gather(
|
||||||
|
*[
|
||||||
|
hass.config_entries.async_forward_entry_unload(entry, component)
|
||||||
|
for component in PLATFORMS
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Shutdown a harmony remote for removal
|
||||||
|
device = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
await device.shutdown()
|
||||||
|
|
||||||
|
if unload_ok:
|
||||||
|
DEVICES.remove(hass.data[DOMAIN][entry.entry_id])
|
||||||
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
|
||||||
|
return unload_ok
|
||||||
|
|
211
homeassistant/components/harmony/config_flow.py
Normal file
211
homeassistant/components/harmony/config_flow.py
Normal file
|
@ -0,0 +1,211 @@
|
||||||
|
"""Config flow for Logitech Harmony Hub integration."""
|
||||||
|
import logging
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import aioharmony.exceptions as harmony_exceptions
|
||||||
|
from aioharmony.harmonyapi import HarmonyAPI
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import config_entries, core, exceptions
|
||||||
|
from homeassistant.components import ssdp
|
||||||
|
from homeassistant.components.remote import (
|
||||||
|
ATTR_ACTIVITY,
|
||||||
|
ATTR_DELAY_SECS,
|
||||||
|
DEFAULT_DELAY_SECS,
|
||||||
|
)
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_NAME
|
||||||
|
from homeassistant.core import callback
|
||||||
|
|
||||||
|
from .const import DOMAIN, UNIQUE_ID
|
||||||
|
from .util import find_unique_id_for_remote
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DATA_SCHEMA = vol.Schema(
|
||||||
|
{vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str}, extra=vol.ALLOW_EXTRA
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_input(hass: core.HomeAssistant, data):
|
||||||
|
"""Validate the user input allows us to connect.
|
||||||
|
|
||||||
|
Data has the keys from DATA_SCHEMA with values provided by the user.
|
||||||
|
"""
|
||||||
|
harmony = HarmonyAPI(ip_address=data[CONF_HOST])
|
||||||
|
|
||||||
|
_LOGGER.debug("harmony:%s", harmony)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not await harmony.connect():
|
||||||
|
await harmony.close()
|
||||||
|
raise CannotConnect
|
||||||
|
except harmony_exceptions.TimeOut:
|
||||||
|
raise CannotConnect
|
||||||
|
|
||||||
|
unique_id = find_unique_id_for_remote(harmony)
|
||||||
|
await harmony.close()
|
||||||
|
|
||||||
|
# As a last resort we get the name from the harmony client
|
||||||
|
# in the event a name was not provided. harmony.name is
|
||||||
|
# usually the ip address but it can be an empty string.
|
||||||
|
if CONF_NAME not in data or data[CONF_NAME] is None or data[CONF_NAME] == "":
|
||||||
|
data[CONF_NAME] = harmony.name
|
||||||
|
|
||||||
|
return {
|
||||||
|
CONF_NAME: data[CONF_NAME],
|
||||||
|
CONF_HOST: data[CONF_HOST],
|
||||||
|
UNIQUE_ID: unique_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Logitech Harmony Hub."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the Harmony config flow."""
|
||||||
|
self.harmony_config = {}
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input=None):
|
||||||
|
"""Handle the initial step."""
|
||||||
|
errors = {}
|
||||||
|
if user_input is not None:
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = await validate_input(self.hass, user_input)
|
||||||
|
except CannotConnect:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
_LOGGER.exception("Unexpected exception")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
|
||||||
|
if "base" not in errors:
|
||||||
|
return await self._async_create_entry_from_valid_input(info, user_input)
|
||||||
|
|
||||||
|
# Return form
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_ssdp(self, discovery_info):
|
||||||
|
"""Handle a discovered Harmony device."""
|
||||||
|
_LOGGER.debug("SSDP discovery_info: %s", discovery_info)
|
||||||
|
|
||||||
|
parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION])
|
||||||
|
friendly_name = discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME]
|
||||||
|
|
||||||
|
# pylint: disable=no-member
|
||||||
|
self.context["title_placeholders"] = {"name": friendly_name}
|
||||||
|
|
||||||
|
self.harmony_config = {
|
||||||
|
CONF_HOST: parsed_url.hostname,
|
||||||
|
CONF_NAME: friendly_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self._host_already_configured(self.harmony_config):
|
||||||
|
return self.async_abort(reason="already_configured")
|
||||||
|
|
||||||
|
return await self.async_step_link()
|
||||||
|
|
||||||
|
async def async_step_link(self, user_input=None):
|
||||||
|
"""Attempt to link with the Harmony."""
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
try:
|
||||||
|
info = await validate_input(self.hass, self.harmony_config)
|
||||||
|
except CannotConnect:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
_LOGGER.exception("Unexpected exception")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
|
||||||
|
if "base" not in errors:
|
||||||
|
return await self._async_create_entry_from_valid_input(info, user_input)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="link",
|
||||||
|
errors=errors,
|
||||||
|
description_placeholders={
|
||||||
|
CONF_HOST: self.harmony_config[CONF_NAME],
|
||||||
|
CONF_NAME: self.harmony_config[CONF_HOST],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_import(self, user_input):
|
||||||
|
"""Handle import."""
|
||||||
|
return await self.async_step_user(user_input)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@callback
|
||||||
|
def async_get_options_flow(config_entry):
|
||||||
|
"""Get the options flow for this handler."""
|
||||||
|
return OptionsFlowHandler(config_entry)
|
||||||
|
|
||||||
|
async def _async_create_entry_from_valid_input(self, validated, user_input):
|
||||||
|
"""Single path to create the config entry from validated input."""
|
||||||
|
await self.async_set_unique_id(validated[UNIQUE_ID])
|
||||||
|
if self._host_already_configured(validated):
|
||||||
|
return self.async_abort(reason="already_configured")
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
config_entry = self.async_create_entry(
|
||||||
|
title=validated[CONF_NAME],
|
||||||
|
data={CONF_NAME: validated[CONF_NAME], CONF_HOST: validated[CONF_HOST]},
|
||||||
|
)
|
||||||
|
# Options from yaml are preserved
|
||||||
|
options = _options_from_user_input(user_input)
|
||||||
|
if options:
|
||||||
|
config_entry["options"] = options
|
||||||
|
return config_entry
|
||||||
|
|
||||||
|
def _host_already_configured(self, user_input):
|
||||||
|
"""See if we already have a harmony matching user input configured."""
|
||||||
|
existing_hosts = {
|
||||||
|
entry.data[CONF_HOST] for entry in self._async_current_entries()
|
||||||
|
}
|
||||||
|
return user_input[CONF_HOST] in existing_hosts
|
||||||
|
|
||||||
|
|
||||||
|
def _options_from_user_input(user_input):
|
||||||
|
options = {}
|
||||||
|
if ATTR_ACTIVITY in user_input:
|
||||||
|
options[ATTR_ACTIVITY] = user_input[ATTR_ACTIVITY]
|
||||||
|
if ATTR_DELAY_SECS in user_input:
|
||||||
|
options[ATTR_DELAY_SECS] = user_input[ATTR_DELAY_SECS]
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||||
|
"""Handle a option flow for Harmony."""
|
||||||
|
|
||||||
|
def __init__(self, config_entry: config_entries.ConfigEntry):
|
||||||
|
"""Initialize options flow."""
|
||||||
|
self.config_entry = config_entry
|
||||||
|
|
||||||
|
async def async_step_init(self, user_input=None):
|
||||||
|
"""Handle options flow."""
|
||||||
|
if user_input is not None:
|
||||||
|
return self.async_create_entry(title="", data=user_input)
|
||||||
|
|
||||||
|
remote = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||||||
|
|
||||||
|
data_schema = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(
|
||||||
|
ATTR_DELAY_SECS,
|
||||||
|
default=self.config_entry.options.get(
|
||||||
|
ATTR_DELAY_SECS, DEFAULT_DELAY_SECS
|
||||||
|
),
|
||||||
|
): vol.Coerce(float),
|
||||||
|
vol.Optional(
|
||||||
|
ATTR_ACTIVITY, default=self.config_entry.options.get(ATTR_ACTIVITY),
|
||||||
|
): vol.In(remote.activity_names),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self.async_show_form(step_id="init", data_schema=data_schema)
|
||||||
|
|
||||||
|
|
||||||
|
class CannotConnect(exceptions.HomeAssistantError):
|
||||||
|
"""Error to indicate we cannot connect."""
|
|
@ -2,3 +2,5 @@
|
||||||
DOMAIN = "harmony"
|
DOMAIN = "harmony"
|
||||||
SERVICE_SYNC = "sync"
|
SERVICE_SYNC = "sync"
|
||||||
SERVICE_CHANGE_CHANNEL = "change_channel"
|
SERVICE_CHANGE_CHANNEL = "change_channel"
|
||||||
|
PLATFORMS = ["remote"]
|
||||||
|
UNIQUE_ID = "unique_id"
|
||||||
|
|
|
@ -4,5 +4,12 @@
|
||||||
"documentation": "https://www.home-assistant.io/integrations/harmony",
|
"documentation": "https://www.home-assistant.io/integrations/harmony",
|
||||||
"requirements": ["aioharmony==0.1.13"],
|
"requirements": ["aioharmony==0.1.13"],
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"codeowners": ["@ehendrix23"]
|
"codeowners": ["@ehendrix23","@bramkragten","@bdraco"],
|
||||||
|
"ssdp": [
|
||||||
|
{
|
||||||
|
"manufacturer": "Logitech",
|
||||||
|
"deviceType": "urn:myharmony-com:device:harmony:1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"config_flow": true
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,36 +21,29 @@ from homeassistant.components.remote import (
|
||||||
DEFAULT_DELAY_SECS,
|
DEFAULT_DELAY_SECS,
|
||||||
PLATFORM_SCHEMA,
|
PLATFORM_SCHEMA,
|
||||||
)
|
)
|
||||||
from homeassistant.const import (
|
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||||
ATTR_ENTITY_ID,
|
from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME
|
||||||
CONF_HOST,
|
from homeassistant.core import HomeAssistant
|
||||||
CONF_NAME,
|
|
||||||
CONF_PORT,
|
|
||||||
EVENT_HOMEASSISTANT_STOP,
|
|
||||||
)
|
|
||||||
from homeassistant.exceptions import PlatformNotReady
|
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.util import slugify
|
|
||||||
|
|
||||||
from .const import DOMAIN, SERVICE_CHANGE_CHANNEL, SERVICE_SYNC
|
from .const import DOMAIN, SERVICE_CHANGE_CHANNEL, SERVICE_SYNC
|
||||||
|
from .util import find_unique_id_for_remote
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
ATTR_CHANNEL = "channel"
|
ATTR_CHANNEL = "channel"
|
||||||
ATTR_CURRENT_ACTIVITY = "current_activity"
|
ATTR_CURRENT_ACTIVITY = "current_activity"
|
||||||
|
|
||||||
DEFAULT_PORT = 8088
|
|
||||||
DEVICES = []
|
DEVICES = []
|
||||||
CONF_DEVICE_CACHE = "harmony_device_cache"
|
|
||||||
|
|
||||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||||
{
|
{
|
||||||
vol.Optional(ATTR_ACTIVITY): cv.string,
|
vol.Optional(ATTR_ACTIVITY): cv.string,
|
||||||
vol.Required(CONF_NAME): cv.string,
|
vol.Required(CONF_NAME): cv.string,
|
||||||
vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): vol.Coerce(float),
|
vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): vol.Coerce(float),
|
||||||
vol.Optional(CONF_HOST): cv.string,
|
vol.Optional(CONF_HOST): cv.string,
|
||||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
# 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_SYNC_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids})
|
||||||
|
@ -65,65 +58,36 @@ HARMONY_CHANGE_CHANNEL_SCHEMA = vol.Schema(
|
||||||
|
|
||||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||||
"""Set up the Harmony platform."""
|
"""Set up the Harmony platform."""
|
||||||
activity = None
|
|
||||||
|
|
||||||
if CONF_DEVICE_CACHE not in hass.data:
|
|
||||||
hass.data[CONF_DEVICE_CACHE] = []
|
|
||||||
|
|
||||||
if discovery_info:
|
if discovery_info:
|
||||||
# Find the discovered device in the list of user configurations
|
# Now handled by ssdp in the config flow
|
||||||
override = next(
|
|
||||||
(
|
|
||||||
c
|
|
||||||
for c in hass.data[CONF_DEVICE_CACHE]
|
|
||||||
if c.get(CONF_NAME) == discovery_info.get(CONF_NAME)
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
port = DEFAULT_PORT
|
|
||||||
delay_secs = DEFAULT_DELAY_SECS
|
|
||||||
if override is not None:
|
|
||||||
activity = override.get(ATTR_ACTIVITY)
|
|
||||||
delay_secs = override.get(ATTR_DELAY_SECS)
|
|
||||||
port = override.get(CONF_PORT, DEFAULT_PORT)
|
|
||||||
|
|
||||||
host = (discovery_info.get(CONF_NAME), discovery_info.get(CONF_HOST), port)
|
|
||||||
|
|
||||||
# Ignore hub name when checking if this hub is known - ip and port only
|
|
||||||
if host[1:] in ((h.host, h.port) for h in DEVICES):
|
|
||||||
_LOGGER.debug("Discovered host already known: %s", host)
|
|
||||||
return
|
|
||||||
elif CONF_HOST in config:
|
|
||||||
host = (config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT))
|
|
||||||
activity = config.get(ATTR_ACTIVITY)
|
|
||||||
delay_secs = config.get(ATTR_DELAY_SECS)
|
|
||||||
else:
|
|
||||||
hass.data[CONF_DEVICE_CACHE].append(config)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
name, address, port = host
|
if CONF_HOST not in config:
|
||||||
_LOGGER.info(
|
_LOGGER.error(
|
||||||
"Loading Harmony Platform: %s at %s:%s, startup activity: %s",
|
"The harmony remote '%s' cannot be setup because configuration now requires a host when configured manually.",
|
||||||
name,
|
config[CONF_NAME],
|
||||||
address,
|
)
|
||||||
port,
|
return
|
||||||
activity,
|
|
||||||
|
hass.async_create_task(
|
||||||
|
hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
harmony_conf_file = hass.config.path(f"harmony_{slugify(name)}.conf")
|
|
||||||
try:
|
|
||||||
device = HarmonyRemote(
|
|
||||||
name, address, port, activity, harmony_conf_file, delay_secs
|
|
||||||
)
|
|
||||||
if not await device.connect():
|
|
||||||
raise PlatformNotReady
|
|
||||||
|
|
||||||
DEVICES.append(device)
|
async def async_setup_entry(
|
||||||
async_add_entities([device])
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
|
||||||
register_services(hass)
|
):
|
||||||
except (ValueError, AttributeError):
|
"""Set up the Harmony config entry."""
|
||||||
raise PlatformNotReady
|
|
||||||
|
device = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
|
||||||
|
_LOGGER.info("Harmony Remote: %s", device)
|
||||||
|
|
||||||
|
async_add_entities([device])
|
||||||
|
register_services(hass)
|
||||||
|
|
||||||
|
|
||||||
def register_services(hass):
|
def register_services(hass):
|
||||||
|
@ -165,11 +129,10 @@ async def _change_channel_service(service):
|
||||||
class HarmonyRemote(remote.RemoteDevice):
|
class HarmonyRemote(remote.RemoteDevice):
|
||||||
"""Remote representation used to control a Harmony device."""
|
"""Remote representation used to control a Harmony device."""
|
||||||
|
|
||||||
def __init__(self, name, host, port, activity, out_path, delay_secs):
|
def __init__(self, name, host, activity, out_path, delay_secs):
|
||||||
"""Initialize HarmonyRemote class."""
|
"""Initialize HarmonyRemote class."""
|
||||||
self._name = name
|
self._name = name
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
|
||||||
self._state = None
|
self._state = None
|
||||||
self._current_activity = None
|
self._current_activity = None
|
||||||
self._default_activity = activity
|
self._default_activity = activity
|
||||||
|
@ -178,6 +141,39 @@ class HarmonyRemote(remote.RemoteDevice):
|
||||||
self._delay_secs = delay_secs
|
self._delay_secs = delay_secs
|
||||||
self._available = False
|
self._available = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def delay_secs(self):
|
||||||
|
"""Delay seconds between sending commands."""
|
||||||
|
return self._delay_secs
|
||||||
|
|
||||||
|
@delay_secs.setter
|
||||||
|
def delay_secs(self, delay_secs):
|
||||||
|
"""Update the delay seconds (from options flow)."""
|
||||||
|
self._delay_secs = delay_secs
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_activity(self):
|
||||||
|
"""Activity used when non specified."""
|
||||||
|
return self._default_activity
|
||||||
|
|
||||||
|
@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 "PowerOff" in activities:
|
||||||
|
activities.remove("PowerOff")
|
||||||
|
|
||||||
|
return activities
|
||||||
|
|
||||||
|
@default_activity.setter
|
||||||
|
def default_activity(self, activity):
|
||||||
|
"""Update the default activity (from options flow)."""
|
||||||
|
self._default_activity = activity
|
||||||
|
|
||||||
async def async_added_to_hass(self):
|
async def async_added_to_hass(self):
|
||||||
"""Complete the initialization."""
|
"""Complete the initialization."""
|
||||||
_LOGGER.debug("%s: Harmony Hub added", self._name)
|
_LOGGER.debug("%s: Harmony Hub added", self._name)
|
||||||
|
@ -193,15 +189,34 @@ class HarmonyRemote(remote.RemoteDevice):
|
||||||
# activity
|
# activity
|
||||||
await self.new_config()
|
await self.new_config()
|
||||||
|
|
||||||
async def shutdown(_):
|
async def shutdown(self):
|
||||||
"""Close connection on shutdown."""
|
"""Close connection on shutdown."""
|
||||||
_LOGGER.debug("%s: Closing Harmony Hub", self._name)
|
_LOGGER.debug("%s: Closing Harmony Hub", self._name)
|
||||||
try:
|
try:
|
||||||
await self._client.close()
|
await self._client.close()
|
||||||
except aioexc.TimeOut:
|
except aioexc.TimeOut:
|
||||||
_LOGGER.warning("%s: Disconnect timed-out", self._name)
|
_LOGGER.warning("%s: Disconnect timed-out", self._name)
|
||||||
|
|
||||||
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown)
|
@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 find_unique_id_for_remote(self._client)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
|
@ -239,7 +254,6 @@ class HarmonyRemote(remote.RemoteDevice):
|
||||||
except aioexc.TimeOut:
|
except aioexc.TimeOut:
|
||||||
_LOGGER.warning("%s: Connection timed-out", self._name)
|
_LOGGER.warning("%s: Connection timed-out", self._name)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def new_activity(self, activity_info: tuple) -> None:
|
def new_activity(self, activity_info: tuple) -> None:
|
||||||
|
|
38
homeassistant/components/harmony/strings.json
Normal file
38
homeassistant/components/harmony/strings.json
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"title": "Logitech Harmony Hub",
|
||||||
|
"flow_title": "Logitech Harmony Hub {name}",
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Setup Logitech Harmony Hub",
|
||||||
|
"data": {
|
||||||
|
"host": "Hostname or IP Address",
|
||||||
|
"name": "Hub Name"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"title": "Setup Logitech Harmony Hub",
|
||||||
|
"description": "Do you want to setup {name} ({host})?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Failed to connect, please try again",
|
||||||
|
"invalid_auth": "Invalid authentication",
|
||||||
|
"unknown": "Unexpected error"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Device is already configured"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"description": "Adjust Harmony Hub Options",
|
||||||
|
"data": {
|
||||||
|
"activity": "The default activity to execute when none is specified.",
|
||||||
|
"delay_secs": "The delay between sending commands."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
15
homeassistant/components/harmony/util.py
Normal file
15
homeassistant/components/harmony/util.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
"""The Logitech Harmony Hub integration utils."""
|
||||||
|
from aioharmony.harmonyapi import HarmonyAPI as HarmonyClient
|
||||||
|
|
||||||
|
|
||||||
|
def find_unique_id_for_remote(harmony: HarmonyClient):
|
||||||
|
"""Find the unique id for both websocket and xmpp clients."""
|
||||||
|
websocket_unique_id = harmony.hub_config.info.get("activeRemoteId")
|
||||||
|
if websocket_unique_id is not None:
|
||||||
|
return websocket_unique_id
|
||||||
|
|
||||||
|
xmpp_unique_id = harmony.config.get("global", {}).get("timeStampHash")
|
||||||
|
if not xmpp_unique_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return xmpp_unique_id.split(";")[-1]
|
|
@ -24,7 +24,7 @@ from homeassistant.helpers.entity_component import EntityComponent
|
||||||
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
|
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
|
||||||
from homeassistant.loader import bind_hass
|
from homeassistant.loader import bind_hass
|
||||||
|
|
||||||
# mypy: allow-untyped-calls
|
# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
@ -40,6 +40,7 @@ FLOWS = [
|
||||||
"gpslogger",
|
"gpslogger",
|
||||||
"griddy",
|
"griddy",
|
||||||
"hangouts",
|
"hangouts",
|
||||||
|
"harmony",
|
||||||
"heos",
|
"heos",
|
||||||
"hisense_aehw4a1",
|
"hisense_aehw4a1",
|
||||||
"homekit_controller",
|
"homekit_controller",
|
||||||
|
|
|
@ -17,6 +17,12 @@ SSDP = {
|
||||||
"manufacturer": "DIRECTV"
|
"manufacturer": "DIRECTV"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"harmony": [
|
||||||
|
{
|
||||||
|
"deviceType": "urn:myharmony-com:device:harmony:1",
|
||||||
|
"manufacturer": "Logitech"
|
||||||
|
}
|
||||||
|
],
|
||||||
"heos": [
|
"heos": [
|
||||||
{
|
{
|
||||||
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
|
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
|
||||||
|
|
|
@ -64,6 +64,9 @@ aioesphomeapi==2.6.1
|
||||||
# homeassistant.components.freebox
|
# homeassistant.components.freebox
|
||||||
aiofreepybox==0.0.8
|
aiofreepybox==0.0.8
|
||||||
|
|
||||||
|
# homeassistant.components.harmony
|
||||||
|
aioharmony==0.1.13
|
||||||
|
|
||||||
# homeassistant.components.homekit_controller
|
# homeassistant.components.homekit_controller
|
||||||
aiohomekit[IP]==0.2.34
|
aiohomekit[IP]==0.2.34
|
||||||
|
|
||||||
|
|
1
tests/components/harmony/__init__.py
Normal file
1
tests/components/harmony/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"""Tests for the Logitech Harmony Hub integration."""
|
148
tests/components/harmony/test_config_flow.py
Normal file
148
tests/components/harmony/test_config_flow.py
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
"""Test the Logitech Harmony Hub config flow."""
|
||||||
|
from asynctest import CoroutineMock, MagicMock, patch
|
||||||
|
|
||||||
|
from homeassistant import config_entries, setup
|
||||||
|
from homeassistant.components.harmony.config_flow import CannotConnect
|
||||||
|
from homeassistant.components.harmony.const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
|
def _get_mock_harmonyapi(connect=None, close=None):
|
||||||
|
harmonyapi_mock = MagicMock()
|
||||||
|
type(harmonyapi_mock).connect = CoroutineMock(return_value=connect)
|
||||||
|
type(harmonyapi_mock).close = CoroutineMock(return_value=close)
|
||||||
|
|
||||||
|
return harmonyapi_mock
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_form(hass):
|
||||||
|
"""Test we get the user form."""
|
||||||
|
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
harmonyapi = _get_mock_harmonyapi(connect=True)
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.harmony.config_flow.HarmonyAPI",
|
||||||
|
return_value=harmonyapi,
|
||||||
|
), patch(
|
||||||
|
"homeassistant.components.harmony.async_setup", return_value=True
|
||||||
|
) as mock_setup, patch(
|
||||||
|
"homeassistant.components.harmony.async_setup_entry", return_value=True,
|
||||||
|
) as mock_setup_entry:
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"host": "1.2.3.4", "name": "friend"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result2["type"] == "create_entry"
|
||||||
|
assert result2["title"] == "friend"
|
||||||
|
assert result2["data"] == {
|
||||||
|
"host": "1.2.3.4",
|
||||||
|
"name": "friend",
|
||||||
|
}
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(mock_setup.mock_calls) == 1
|
||||||
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form_import(hass):
|
||||||
|
"""Test we get the form with import source."""
|
||||||
|
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||||
|
|
||||||
|
harmonyapi = _get_mock_harmonyapi(connect=True)
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.harmony.config_flow.HarmonyAPI",
|
||||||
|
return_value=harmonyapi,
|
||||||
|
), patch(
|
||||||
|
"homeassistant.components.harmony.async_setup", return_value=True
|
||||||
|
) as mock_setup, patch(
|
||||||
|
"homeassistant.components.harmony.async_setup_entry", return_value=True,
|
||||||
|
) as mock_setup_entry:
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_IMPORT},
|
||||||
|
data={
|
||||||
|
"host": "1.2.3.4",
|
||||||
|
"name": "friend",
|
||||||
|
"activity": "Watch TV",
|
||||||
|
"delay_secs": 0.9,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["title"] == "friend"
|
||||||
|
assert result["data"] == {
|
||||||
|
"host": "1.2.3.4",
|
||||||
|
"name": "friend",
|
||||||
|
}
|
||||||
|
assert result["options"] == {
|
||||||
|
"activity": "Watch TV",
|
||||||
|
"delay_secs": 0.9,
|
||||||
|
}
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(mock_setup.mock_calls) == 1
|
||||||
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form_ssdp(hass):
|
||||||
|
"""Test we get the form with ssdp source."""
|
||||||
|
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_SSDP},
|
||||||
|
data={
|
||||||
|
"friendlyName": "Harmony Hub",
|
||||||
|
"ssdp_location": "http://192.168.209.238:8088/description",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "link"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
harmonyapi = _get_mock_harmonyapi(connect=True)
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.harmony.config_flow.HarmonyAPI",
|
||||||
|
return_value=harmonyapi,
|
||||||
|
), patch(
|
||||||
|
"homeassistant.components.harmony.async_setup", return_value=True
|
||||||
|
) as mock_setup, patch(
|
||||||
|
"homeassistant.components.harmony.async_setup_entry", return_value=True,
|
||||||
|
) as mock_setup_entry:
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {},)
|
||||||
|
|
||||||
|
assert result2["type"] == "create_entry"
|
||||||
|
assert result2["title"] == "Harmony Hub"
|
||||||
|
assert result2["data"] == {
|
||||||
|
"host": "192.168.209.238",
|
||||||
|
"name": "Harmony Hub",
|
||||||
|
}
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(mock_setup.mock_calls) == 1
|
||||||
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form_cannot_connect(hass):
|
||||||
|
"""Test we handle cannot connect error."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.harmony.config_flow.HarmonyAPI",
|
||||||
|
side_effect=CannotConnect,
|
||||||
|
):
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
"host": "1.2.3.4",
|
||||||
|
"name": "friend",
|
||||||
|
"activity": "Watch TV",
|
||||||
|
"delay_secs": 0.2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result2["type"] == "form"
|
||||||
|
assert result2["errors"] == {"base": "cannot_connect"}
|
Loading…
Add table
Add a link
Reference in a new issue