diff --git a/CODEOWNERS b/CODEOWNERS index 121d1875202..645fdce52a2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/harmony/const.py b/homeassistant/components/harmony/const.py index 0d8d893a98e..c8e15ed0b0f 100644 --- a/homeassistant/components/harmony/const.py +++ b/homeassistant/components/harmony/const.py @@ -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" diff --git a/homeassistant/components/harmony/connection_state.py b/homeassistant/components/harmony/entity.py similarity index 70% rename from homeassistant/components/harmony/connection_state.py rename to homeassistant/components/harmony/entity.py index 84ad353480c..24c72a771e7 100644 --- a/homeassistant/components/harmony/connection_state.py +++ b/homeassistant/components/harmony/entity.py @@ -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.""" diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index e28d525539b..f35f4e99303 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -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", diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index 593fbf3cb22..806b638aee8 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -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) diff --git a/homeassistant/components/harmony/select.py b/homeassistant/components/harmony/select.py new file mode 100644 index 00000000000..18f273e4bfb --- /dev/null +++ b/homeassistant/components/harmony/select.py @@ -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() diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py index a45b43fce0f..02885289a06 100644 --- a/homeassistant/components/harmony/switch.py +++ b/homeassistant/components/harmony/switch.py @@ -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) diff --git a/tests/components/harmony/const.py b/tests/components/harmony/const.py index 488fe30dec3..9677883d25f 100644 --- a/tests/components/harmony/const.py +++ b/tests/components/harmony/const.py @@ -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 diff --git a/tests/components/harmony/test_init.py b/tests/components/harmony/test_init.py index 29a1ff26b82..d64e5b61701 100644 --- a/tests/components/harmony/test_init.py +++ b/tests/components/harmony/test_init.py @@ -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" diff --git a/tests/components/harmony/test_remote.py b/tests/components/harmony/test_remote.py index df75485e30d..0a176518131 100644 --- a/tests/components/harmony/test_remote.py +++ b/tests/components/harmony/test_remote.py @@ -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): diff --git a/tests/components/harmony/test_select.py b/tests/components/harmony/test_select.py new file mode 100644 index 00000000000..4607f035893 --- /dev/null +++ b/tests/components/harmony/test_select.py @@ -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() diff --git a/tests/components/harmony/test_switch.py b/tests/components/harmony/test_switch.py index 1940c54e112..d7af3680dd9 100644 --- a/tests/components/harmony/test_switch.py +++ b/tests/components/harmony/test_switch.py @@ -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)