Make devices and activities visible as harmony attributes (#37559)

* Make devices and activities visibile as harmony attributes

* Allow restoring previous activity, add tests

* fix test

* Kill activity_notify with fire

* remove trailing ,
This commit is contained in:
J. Nick Koston 2020-07-07 09:02:22 -05:00 committed by GitHub
parent ae7d464878
commit 76be95d7e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 140 additions and 54 deletions

View file

@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import ATTR_ACTIVITY_NOTIFY, DOMAIN, HARMONY_OPTIONS_UPDATE, PLATFORMS
from .const import DOMAIN, HARMONY_OPTIONS_UPDATE, PLATFORMS
from .remote import HarmonyRemote
_LOGGER = logging.getLogger(__name__)
@ -38,18 +38,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
name = entry.data[CONF_NAME]
activity = entry.options.get(ATTR_ACTIVITY)
delay_secs = entry.options.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS)
activity_notify = entry.options.get(ATTR_ACTIVITY_NOTIFY, False)
harmony_conf_file = hass.config.path(f"harmony_{entry.unique_id}.conf")
try:
device = HarmonyRemote(
name,
entry.unique_id,
address,
activity,
harmony_conf_file,
delay_secs,
activity_notify,
name, entry.unique_id, address, activity, harmony_conf_file, delay_secs
)
connected_ok = await device.connect()
except (asyncio.TimeoutError, ValueError, AttributeError):

View file

@ -14,7 +14,7 @@ from homeassistant.components.remote import (
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import callback
from .const import ATTR_ACTIVITY_NOTIFY, DOMAIN, UNIQUE_ID
from .const import DOMAIN, PREVIOUS_ACTIVE_ACTIVITY, UNIQUE_ID
from .util import (
find_best_name_for_remote,
find_unique_id_for_remote,
@ -148,7 +148,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def _async_create_entry_from_valid_input(self, validated, user_input):
"""Single path to create the config entry from validated input."""
data = {CONF_NAME: validated[CONF_NAME], CONF_HOST: validated[CONF_HOST]}
data = {
CONF_NAME: validated[CONF_NAME],
CONF_HOST: validated[CONF_HOST],
}
# Options from yaml are preserved, we will pull them out when
# we setup the config entry
data.update(_options_from_user_input(user_input))
@ -162,8 +165,6 @@ def _options_from_user_input(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]
if ATTR_ACTIVITY_NOTIFY in user_input:
options[ATTR_ACTIVITY_NOTIFY] = user_input[ATTR_ACTIVITY_NOTIFY]
return options
@ -190,12 +191,11 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
),
): vol.Coerce(float),
vol.Optional(
ATTR_ACTIVITY, default=self.config_entry.options.get(ATTR_ACTIVITY),
): vol.In(remote.activity_names),
vol.Optional(
ATTR_ACTIVITY_NOTIFY,
default=self.config_entry.options.get(ATTR_ACTIVITY_NOTIFY, False),
): vol.Coerce(bool),
ATTR_ACTIVITY,
default=self.config_entry.options.get(
ATTR_ACTIVITY, PREVIOUS_ACTIVE_ACTIVITY
),
): vol.In([PREVIOUS_ACTIVE_ACTIVITY, *remote.activity_names]),
}
)
return self.async_show_form(step_id="init", data_schema=data_schema)

View file

@ -6,4 +6,8 @@ PLATFORMS = ["remote"]
UNIQUE_ID = "unique_id"
ACTIVITY_POWER_OFF = "PowerOff"
HARMONY_OPTIONS_UPDATE = "harmony_options_update"
ATTR_ACTIVITY_NOTIFY = "activity_notify"
ATTR_ACTIVITY_LIST = "activity_list"
ATTR_DEVICES_LIST = "devices_list"
ATTR_LAST_ACTIVITY = "last_activity"
ATTR_CURRENT_ACTIVITY = "current_activity"
PREVIOUS_ACTIVE_ACTIVITY = "Previous Active Activity"

View file

@ -25,12 +25,17 @@ 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_NOTIFY,
ATTR_ACTIVITY_LIST,
ATTR_CURRENT_ACTIVITY,
ATTR_DEVICES_LIST,
ATTR_LAST_ACTIVITY,
DOMAIN,
HARMONY_OPTIONS_UPDATE,
PREVIOUS_ACTIVE_ACTIVITY,
SERVICE_CHANGE_CHANNEL,
SERVICE_SYNC,
UNIQUE_ID,
@ -40,6 +45,7 @@ from .util import (
find_matching_config_entries_for_host,
find_unique_id_for_remote,
get_harmony_client_if_available,
list_names_from_hublist,
)
_LOGGER = logging.getLogger(__name__)
@ -48,7 +54,6 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
ATTR_CHANNEL = "channel"
ATTR_CURRENT_ACTIVITY = "current_activity"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
@ -123,12 +128,10 @@ async def async_setup_entry(
)
class HarmonyRemote(remote.RemoteEntity):
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, activity_notify
):
def __init__(self, name, unique_id, host, activity, out_path, delay_secs):
"""Initialize HarmonyRemote class."""
self._name = name
self.host = host
@ -140,7 +143,7 @@ class HarmonyRemote(remote.RemoteEntity):
self.delay_secs = delay_secs
self._available = False
self._unique_id = unique_id
self._activity_notify = activity_notify
self._last_activity = None
@property
def activity_names(self):
@ -163,26 +166,20 @@ class HarmonyRemote(remote.RemoteEntity):
if ATTR_ACTIVITY in data:
self.default_activity = data[ATTR_ACTIVITY]
if ATTR_ACTIVITY_NOTIFY in data:
self._activity_notify = data[ATTR_ACTIVITY_NOTIFY]
self._update_callbacks()
def _update_callbacks(self):
callbacks = {
"config_updated": self.new_config,
"connect": self.got_connected,
"disconnect": self.got_disconnected,
"new_activity_starting": None,
"new_activity_starting": self.new_activity,
"new_activity": None,
}
if self._activity_notify:
callbacks["new_activity_starting"] = self.new_activity
else:
callbacks["new_activity"] = self.new_activity
self._client.callbacks = ClientCallbackType(**callbacks)
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()
@ -199,6 +196,19 @@ class HarmonyRemote(remote.RemoteEntity):
# 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)
@ -241,7 +251,14 @@ class HarmonyRemote(remote.RemoteEntity):
@property
def device_state_attributes(self):
"""Add platform specific attributes."""
return {ATTR_CURRENT_ACTIVITY: self._current_activity}
return {
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):
@ -271,6 +288,11 @@ class HarmonyRemote(remote.RemoteEntity):
activity_id, activity_name = activity_info
_LOGGER.debug("%s: activity reported as: %s", self._name, activity_name)
self._current_activity = 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()
@ -306,6 +328,16 @@ class HarmonyRemote(remote.RemoteEntity):
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
if activity.isdigit() or activity == "-1":

View file

@ -28,8 +28,7 @@
"description": "Adjust Harmony Hub Options",
"data": {
"activity": "The default activity to execute when none is specified.",
"delay_secs": "The delay between sending commands.",
"activity_notify": "Update current activity on start of activity switch."
"delay_secs": "The delay between sending commands."
}
}
}

View file

@ -27,7 +27,6 @@
"init": {
"data": {
"activity": "The default activity to execute when none is specified.",
"activity_notify": "Update current activity on start of activity switch.",
"delay_secs": "The delay between sending commands."
},
"description": "Adjust Harmony Hub Options"

View file

@ -49,3 +49,14 @@ def find_matching_config_entries_for_host(hass, host):
if entry.data[CONF_HOST] == host:
return entry
return None
def list_names_from_hublist(hub_list):
"""Extract the name key value from a hub list of names."""
if not hub_list:
return []
return [
element["name"]
for element in hub_list
if element.get("name") and element.get("id") != -1
]

View file

@ -1,9 +1,11 @@
"""Test the Logitech Harmony Hub config flow."""
from homeassistant import config_entries, setup
from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.harmony.config_flow import CannotConnect
from homeassistant.components.harmony.const import DOMAIN
from homeassistant.components.harmony.const import DOMAIN, PREVIOUS_ACTIVE_ACTIVITY
from homeassistant.const import CONF_HOST, CONF_NAME
from tests.async_mock import AsyncMock, MagicMock, patch
from tests.async_mock import AsyncMock, MagicMock, PropertyMock, patch
from tests.common import MockConfigEntry
def _get_mock_harmonyapi(connect=None, close=None):
@ -14,6 +16,23 @@ def _get_mock_harmonyapi(connect=None, close=None):
return harmonyapi_mock
def _get_mock_harmonyclient():
harmonyclient_mock = MagicMock()
type(harmonyclient_mock).connect = AsyncMock()
type(harmonyclient_mock).close = AsyncMock()
type(harmonyclient_mock).get_activity_name = MagicMock(return_value="Watch TV")
type(harmonyclient_mock.hub_config).activities = PropertyMock(
return_value=[{"name": "Watch TV", "id": 123}]
)
type(harmonyclient_mock.hub_config).devices = PropertyMock(
return_value=[{"name": "My TV", "id": 1234}]
)
type(harmonyclient_mock.hub_config).info = PropertyMock(return_value={})
type(harmonyclient_mock.hub_config).hub_state = PropertyMock(return_value={})
return harmonyclient_mock
async def test_user_form(hass):
"""Test we get the user form."""
await setup.async_setup_component(hass, "persistent_notification", {})
@ -37,10 +56,7 @@ async def test_user_form(hass):
assert result2["type"] == "create_entry"
assert result2["title"] == "friend"
assert result2["data"] == {
"host": "1.2.3.4",
"name": "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
@ -66,7 +82,6 @@ async def test_form_import(hass):
"name": "friend",
"activity": "Watch TV",
"delay_secs": 0.9,
"activity_notify": True,
"unique_id": "555234534543",
},
)
@ -79,7 +94,6 @@ async def test_form_import(hass):
"name": "friend",
"activity": "Watch TV",
"delay_secs": 0.9,
"activity_notify": True,
}
# It is not possible to import options at this time
# so they end up in the config entry data and are
@ -125,10 +139,7 @@ async def test_form_ssdp(hass):
assert result2["type"] == "create_entry"
assert result2["title"] == "Harmony Hub"
assert result2["data"] == {
"host": "192.168.1.12",
"name": "Harmony Hub",
}
assert result2["data"] == {"host": "192.168.1.12", "name": "Harmony Hub"}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
@ -150,9 +161,46 @@ async def test_form_cannot_connect(hass):
"name": "friend",
"activity": "Watch TV",
"delay_secs": 0.2,
"activity_notify": True,
},
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"}
async def test_options_flow(hass):
"""Test config flow options."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="abcde12345",
data={CONF_HOST: "1.2.3.4", CONF_NAME: "Guest Room"},
options={"activity": "Watch TV", "delay_secs": 0.5},
)
harmony_client = _get_mock_harmonyclient()
with patch(
"aioharmony.harmonyapi.HarmonyClient", return_value=harmony_client,
), patch("homeassistant.components.harmony.remote.HarmonyRemote.write_config_file"):
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(config_entry.entry_id)
await hass.async_block_till_done()
assert await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"activity": PREVIOUS_ACTIVE_ACTIVITY, "delay_secs": 0.4},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options == {
"activity": PREVIOUS_ACTIVE_ACTIVITY,
"delay_secs": 0.4,
}