Add select entity to Logitech Harmony (#53943)

Co-authored-by: J. Nick Koston <nick@koston.org>
pull/55391/head
Matthieu 2021-08-28 21:10:19 +02:00 committed by GitHub
parent 778fa2e3fe
commit 979797136a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 278 additions and 94 deletions

View File

@ -202,7 +202,7 @@ homeassistant/components/group/* @home-assistant/core
homeassistant/components/growatt_server/* @indykoning @muppet3000 @JasperPlant
homeassistant/components/guardian/* @bachya
homeassistant/components/habitica/* @ASMfreaK @leikoilja
homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco @mkeesey
homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco @mkeesey @Aohzan
homeassistant/components/hassio/* @home-assistant/supervisor
homeassistant/components/heatmiser/* @andylockran
homeassistant/components/heos/* @andrewsayre

View File

@ -2,7 +2,7 @@
DOMAIN = "harmony"
SERVICE_SYNC = "sync"
SERVICE_CHANGE_CHANNEL = "change_channel"
PLATFORMS = ["remote", "switch"]
PLATFORMS = ["remote", "switch", "select"]
UNIQUE_ID = "unique_id"
ACTIVITY_POWER_OFF = "PowerOff"
HARMONY_OPTIONS_UPDATE = "harmony_options_update"

View File

@ -1,20 +1,31 @@
"""Mixin class for handling connection state changes."""
"""Base class Harmony entities."""
import logging
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_call_later
from .data import HarmonyData
_LOGGER = logging.getLogger(__name__)
TIME_MARK_DISCONNECTED = 10
class ConnectionStateMixin:
"""Base implementation for connection state handling."""
class HarmonyEntity(Entity):
"""Base entity for Harmony with connection state handling."""
def __init__(self):
"""Initialize this mixin instance."""
def __init__(self, data: HarmonyData) -> None:
"""Initialize the Harmony base entity."""
super().__init__()
self._unsub_mark_disconnected = None
self._name = data.name
self._data = data
self._attr_should_poll = False
@property
def available(self) -> bool:
"""Return True if we're connected to the Hub, otherwise False."""
return self._data.available
async def async_got_connected(self, _=None):
"""Notification that we're connected to the HUB."""

View File

@ -3,7 +3,13 @@
"name": "Logitech Harmony Hub",
"documentation": "https://www.home-assistant.io/integrations/harmony",
"requirements": ["aioharmony==0.2.7"],
"codeowners": ["@ehendrix23", "@bramkragten", "@bdraco", "@mkeesey"],
"codeowners": [
"@ehendrix23",
"@bramkragten",
"@bdraco",
"@mkeesey",
"@Aohzan"
],
"ssdp": [
{
"manufacturer": "Logitech",

View File

@ -21,7 +21,6 @@ import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.restore_state import RestoreEntity
from .connection_state import ConnectionStateMixin
from .const import (
ACTIVITY_POWER_OFF,
ATTR_ACTIVITY_STARTING,
@ -34,6 +33,7 @@ from .const import (
SERVICE_CHANGE_CHANNEL,
SERVICE_SYNC,
)
from .entity import HarmonyEntity
from .subscriber import HarmonyCallback
_LOGGER = logging.getLogger(__name__)
@ -76,28 +76,24 @@ async def async_setup_entry(
)
class HarmonyRemote(ConnectionStateMixin, remote.RemoteEntity, RestoreEntity):
class HarmonyRemote(HarmonyEntity, remote.RemoteEntity, RestoreEntity):
"""Remote representation used to control a Harmony device."""
def __init__(self, data, activity, delay_secs, out_path):
"""Initialize HarmonyRemote class."""
super().__init__()
self._data = data
self._name = data.name
super().__init__(data=data)
self._state = None
self._current_activity = ACTIVITY_POWER_OFF
self.default_activity = activity
self._activity_starting = None
self._is_initial_update = True
self.delay_secs = delay_secs
self._unique_id = data.unique_id
self._last_activity = None
self._config_path = out_path
@property
def supported_features(self):
"""Supported features for the remote."""
return SUPPORT_ACTIVITY
self._attr_unique_id = data.unique_id
self._attr_device_info = self._data.device_info(DOMAIN)
self._attr_name = data.name
self._attr_supported_features = SUPPORT_ACTIVITY
async def _async_update_options(self, data):
"""Change options when the options flow does."""
@ -128,7 +124,7 @@ class HarmonyRemote(ConnectionStateMixin, remote.RemoteEntity, RestoreEntity):
"""Complete the initialization."""
await super().async_added_to_hass()
_LOGGER.debug("%s: Harmony Hub added", self._name)
_LOGGER.debug("%s: Harmony Hub added", self.name)
self.async_on_remove(self._clear_disconnection_delay)
self._setup_callbacks()
@ -158,26 +154,6 @@ class HarmonyRemote(ConnectionStateMixin, remote.RemoteEntity, RestoreEntity):
self._last_activity = last_state.attributes[ATTR_LAST_ACTIVITY]
@property
def device_info(self):
"""Return device info."""
return self._data.device_info(DOMAIN)
@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 current_activity(self):
"""Return the current activity."""
@ -202,16 +178,11 @@ class HarmonyRemote(ConnectionStateMixin, remote.RemoteEntity, RestoreEntity):
"""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._data.available
@callback
def async_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)
_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
@ -227,7 +198,7 @@ class HarmonyRemote(ConnectionStateMixin, remote.RemoteEntity, RestoreEntity):
async def async_new_config(self, _=None):
"""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.async_new_activity(self._data.current_activity)
await self.hass.async_add_executor_job(self.write_config_file)

View File

@ -0,0 +1,75 @@
"""Support for Harmony Hub select activities."""
from __future__ import annotations
import logging
from homeassistant.components.select import SelectEntity
from homeassistant.const import CONF_NAME
from homeassistant.core import callback
from .const import ACTIVITY_POWER_OFF, DOMAIN, HARMONY_DATA
from .data import HarmonyData
from .entity import HarmonyEntity
from .subscriber import HarmonyCallback
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass, entry, async_add_entities):
"""Set up harmony activities select."""
data = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA]
_LOGGER.debug("creating select for %s hub activities", entry.data[CONF_NAME])
async_add_entities(
[HarmonyActivitySelect(f"{entry.data[CONF_NAME]} Activities", data)]
)
class HarmonyActivitySelect(HarmonyEntity, SelectEntity):
"""Select representation of a Harmony activities."""
def __init__(self, name: str, data: HarmonyData) -> None:
"""Initialize HarmonyActivitySelect class."""
super().__init__(data=data)
self._data = data
self._attr_unique_id = self._data.unique_id
self._attr_device_info = self._data.device_info(DOMAIN)
self._attr_name = name
@property
def icon(self):
"""Return a representative icon."""
if not self.available or self.current_option == ACTIVITY_POWER_OFF:
return "mdi:remote-tv-off"
return "mdi:remote-tv"
@property
def options(self) -> list[str]:
"""Return a set of selectable options."""
return [ACTIVITY_POWER_OFF] + sorted(self._data.activity_names)
@property
def current_option(self):
"""Return the current activity."""
_, activity_name = self._data.current_activity
return activity_name
async def async_select_option(self, option: str) -> None:
"""Change the current activity."""
await self._data.async_start_activity(option)
async def async_added_to_hass(self):
"""Call when entity is added to hass."""
callbacks = {
"connected": self.async_got_connected,
"disconnected": self.async_got_disconnected,
"activity_starting": self._async_activity_update,
"activity_started": self._async_activity_update,
"config_updated": None,
}
self.async_on_remove(self._data.async_subscribe(HarmonyCallback(**callbacks)))
@callback
def _async_activity_update(self, activity_info: tuple):
self.async_write_ha_state()

View File

@ -5,9 +5,9 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.const import CONF_NAME
from homeassistant.core import callback
from .connection_state import ConnectionStateMixin
from .const import DOMAIN, HARMONY_DATA
from .data import HarmonyData
from .entity import HarmonyEntity
from .subscriber import HarmonyCallback
_LOGGER = logging.getLogger(__name__)
@ -27,31 +27,18 @@ async def async_setup_entry(hass, entry, async_add_entities):
async_add_entities(switches, True)
class HarmonyActivitySwitch(ConnectionStateMixin, SwitchEntity):
class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity):
"""Switch representation of a Harmony activity."""
def __init__(self, name: str, activity: dict, data: HarmonyData) -> None:
"""Initialize HarmonyActivitySwitch class."""
super().__init__()
self._name = name
super().__init__(data=data)
self._activity_name = activity["label"]
self._activity_id = activity["id"]
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"activity_{self._activity_id}"
@property
def device_info(self):
"""Return device info."""
return self._data.device_info(DOMAIN)
self._attr_entity_registry_enabled_default = False
self._attr_unique_id = f"activity_{self._activity_id}"
self._attr_name = name
self._attr_device_info = self._data.device_info(DOMAIN)
@property
def is_on(self):
@ -59,16 +46,6 @@ class HarmonyActivitySwitch(ConnectionStateMixin, SwitchEntity):
_, activity_name = self._data.current_activity
return activity_name == self._activity_name
@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_name)

View File

@ -5,6 +5,7 @@ ENTITY_REMOTE = "remote.guest_room"
ENTITY_WATCH_TV = "switch.guest_room_watch_tv"
ENTITY_PLAY_MUSIC = "switch.guest_room_play_music"
ENTITY_NILE_TV = "switch.guest_room_nile_tv"
ENTITY_SELECT = "select.guest_room_activities"
WATCH_TV_ACTIVITY_ID = 123
PLAY_MUSIC_ACTIVITY_ID = 456

View File

@ -7,6 +7,7 @@ from homeassistant.setup import async_setup_component
from .const import (
ENTITY_NILE_TV,
ENTITY_PLAY_MUSIC,
ENTITY_SELECT,
ENTITY_WATCH_TV,
HUB_NAME,
NILE_TV_ACTIVITY_ID,
@ -55,6 +56,13 @@ async def test_unique_id_migration(mock_hc, hass, mock_write_config):
platform="harmony",
config_entry_id=entry.entry_id,
),
# select entity
ENTITY_SELECT: er.RegistryEntry(
entity_id=ENTITY_SELECT,
unique_id=f"{HUB_NAME}_activities",
platform="harmony",
config_entry_id=entry.entry_id,
),
},
)
assert await async_setup_component(hass, DOMAIN, {})
@ -70,3 +78,6 @@ async def test_unique_id_migration(mock_hc, hass, mock_write_config):
switch_music = ent_reg.async_get(ENTITY_PLAY_MUSIC)
assert switch_music.unique_id == f"activity_{PLAY_MUSIC_ACTIVITY_ID}"
select_activities = ent_reg.async_get(ENTITY_SELECT)
assert select_activities.unique_id == f"{HUB_NAME}_activities"

View File

@ -33,7 +33,7 @@ from homeassistant.const import (
from homeassistant.util import utcnow
from .conftest import ACTIVITIES_TO_IDS, TV_DEVICE_ID, TV_DEVICE_NAME
from .const import ENTITY_PLAY_MUSIC, ENTITY_REMOTE, ENTITY_WATCH_TV, HUB_NAME
from .const import ENTITY_REMOTE, HUB_NAME
from tests.common import MockConfigEntry, async_fire_time_changed
@ -91,10 +91,10 @@ async def test_remote_toggles(mock_hc, hass, mock_write_config):
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)
# mocks start remote with Watch TV default activity
state = hass.states.get(ENTITY_REMOTE)
assert state.state == STATE_ON
assert state.attributes.get("current_activity") == "Watch TV"
# turn off remote
await hass.services.async_call(
@ -105,9 +105,9 @@ async def test_remote_toggles(mock_hc, hass, mock_write_config):
)
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)
state = hass.states.get(ENTITY_REMOTE)
assert state.state == STATE_OFF
assert state.attributes.get("current_activity") == "PowerOff"
# turn on remote, restoring the last activity
await hass.services.async_call(
@ -118,9 +118,9 @@ async def test_remote_toggles(mock_hc, hass, mock_write_config):
)
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)
state = hass.states.get(ENTITY_REMOTE)
assert state.state == STATE_ON
assert state.attributes.get("current_activity") == "Watch TV"
# send new activity command, with activity name
await hass.services.async_call(
@ -131,9 +131,9 @@ async def test_remote_toggles(mock_hc, hass, mock_write_config):
)
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)
state = hass.states.get(ENTITY_REMOTE)
assert state.state == STATE_ON
assert state.attributes.get("current_activity") == "Play Music"
# send new activity command, with activity id
await hass.services.async_call(
@ -144,9 +144,9 @@ async def test_remote_toggles(mock_hc, hass, mock_write_config):
)
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)
state = hass.states.get(ENTITY_REMOTE)
assert state.state == STATE_ON
assert state.attributes.get("current_activity") == "Watch TV"
async def test_async_send_command(mock_hc, harmony_client, hass, mock_write_config):

View File

@ -0,0 +1,113 @@
"""Test the Logitech Harmony Hub activity select."""
from datetime import timedelta
from homeassistant.components.harmony.const import DOMAIN
from homeassistant.components.select import (
ATTR_OPTION,
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_HOST,
CONF_NAME,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
)
from homeassistant.util import utcnow
from .const import ENTITY_REMOTE, ENTITY_SELECT, HUB_NAME
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_connection_state_changes(
harmony_client, 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()
# mocks start with current activity == Watch TV
assert hass.states.is_state(ENTITY_SELECT, "Watch TV")
harmony_client.mock_disconnection()
await hass.async_block_till_done()
# Entities do not immediately show as unavailable
assert hass.states.is_state(ENTITY_SELECT, "Watch TV")
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_SELECT, STATE_UNAVAILABLE)
harmony_client.mock_reconnection()
await hass.async_block_till_done()
assert hass.states.is_state(ENTITY_SELECT, "Watch TV")
async def test_options(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()
# assert we have all options
state = hass.states.get(ENTITY_SELECT)
assert state.attributes.get("options") == [
"PowerOff",
"Nile-TV",
"Play Music",
"Watch TV",
]
async def test_select_option(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_SELECT, "Watch TV")
# launch Play Music activity
await _select_option_and_wait(hass, ENTITY_SELECT, "Play Music")
assert hass.states.is_state(ENTITY_REMOTE, STATE_ON)
assert hass.states.is_state(ENTITY_SELECT, "Play Music")
# turn off harmony by selecting PowerOff activity
await _select_option_and_wait(hass, ENTITY_SELECT, "PowerOff")
assert hass.states.is_state(ENTITY_REMOTE, STATE_OFF)
assert hass.states.is_state(ENTITY_SELECT, "PowerOff")
async def _select_option_and_wait(hass, entity, option):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: entity,
ATTR_OPTION: option,
},
blocking=True,
)
await hass.async_block_till_done()

View File

@ -16,6 +16,7 @@ from homeassistant.const import (
STATE_ON,
STATE_UNAVAILABLE,
)
from homeassistant.helpers import entity_registry
from homeassistant.util import utcnow
from .const import ENTITY_PLAY_MUSIC, ENTITY_REMOTE, ENTITY_WATCH_TV, HUB_NAME
@ -35,6 +36,17 @@ async def test_connection_state_changes(
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
# check if switch entities are disabled by default
assert not hass.states.get(ENTITY_WATCH_TV)
assert not hass.states.get(ENTITY_PLAY_MUSIC)
# enable switch entities
ent_reg = entity_registry.async_get(hass)
ent_reg.async_update_entity(ENTITY_WATCH_TV, disabled_by=None)
ent_reg.async_update_entity(ENTITY_PLAY_MUSIC, disabled_by=None)
await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
# mocks start with current activity == Watch TV
assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON)
assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF)
@ -78,6 +90,13 @@ async def test_switch_toggles(mock_hc, hass, mock_write_config):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
# enable switch entities
ent_reg = entity_registry.async_get(hass)
ent_reg.async_update_entity(ENTITY_WATCH_TV, disabled_by=None)
ent_reg.async_update_entity(ENTITY_PLAY_MUSIC, disabled_by=None)
await hass.config_entries.async_reload(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)