diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 7b85828377a..e6a5225e30e 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -152,6 +152,7 @@ SONOS_CHECK_ACTIVITY = "sonos_check_activity" SONOS_CREATE_ALARM = "sonos_create_alarm" SONOS_CREATE_AUDIO_FORMAT_SENSOR = "sonos_create_audio_format_sensor" SONOS_CREATE_BATTERY = "sonos_create_battery" +SONOS_CREATE_FAVORITES_SENSOR = "sonos_create_favorites_sensor" SONOS_CREATE_MIC_SENSOR = "sonos_create_mic_sensor" SONOS_CREATE_SWITCHES = "sonos_create_switches" SONOS_CREATE_LEVELS = "sonos_create_levels" diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 8565fe08e9c..ba1f72cd56b 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -13,13 +13,7 @@ import homeassistant.helpers.device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity -from .const import ( - DATA_SONOS, - DOMAIN, - SONOS_FALLBACK_POLL, - SONOS_FAVORITES_UPDATED, - SONOS_STATE_UPDATED, -) +from .const import DATA_SONOS, DOMAIN, SONOS_FALLBACK_POLL, SONOS_STATE_UPDATED from .exception import SonosUpdateError from .speaker import SonosSpeaker @@ -54,13 +48,6 @@ class SonosEntity(Entity): self.async_write_ha_state, ) ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SONOS_FAVORITES_UPDATED}-{self.soco.household_id}", - self.async_write_ha_state, - ) - ) async def async_will_remove_from_hass(self) -> None: """Clean up when entity is removed.""" diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index fd651b7740c..5284a5f6745 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -11,9 +11,9 @@ from soco.data_structures import DidlFavorite from soco.events_base import Event as SonosEvent from soco.exceptions import SoCoException -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send -from .const import SONOS_FAVORITES_UPDATED +from .const import SONOS_CREATE_FAVORITES_SENSOR, SONOS_FAVORITES_UPDATED from .helpers import soco_error from .household_coordinator import SonosHouseholdCoordinator @@ -37,6 +37,16 @@ class SonosFavorites(SonosHouseholdCoordinator): favorites = self._favorites.copy() return iter(favorites) + def setup(self, soco: SoCo) -> None: + """Override to send a signal on base class setup completion.""" + super().setup(soco) + dispatcher_send(self.hass, SONOS_CREATE_FAVORITES_SENSOR, self) + + @property + def count(self) -> int: + """Return the number of favorites.""" + return len(self._favorites) + def lookup_by_item_id(self, item_id: str) -> DidlFavorite | None: """Return the favorite object with the provided item_id.""" return next((fav for fav in self._favorites if fav.item_id == item_id), None) diff --git a/homeassistant/components/sonos/sensor.py b/homeassistant/components/sonos/sensor.py index f011cb2d754..380d1a3b9b6 100644 --- a/homeassistant/components/sonos/sensor.py +++ b/homeassistant/components/sonos/sensor.py @@ -11,8 +11,15 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import SONOS_CREATE_AUDIO_FORMAT_SENSOR, SONOS_CREATE_BATTERY, SOURCE_TV +from .const import ( + SONOS_CREATE_AUDIO_FORMAT_SENSOR, + SONOS_CREATE_BATTERY, + SONOS_CREATE_FAVORITES_SENSOR, + SONOS_FAVORITES_UPDATED, + SOURCE_TV, +) from .entity import SonosEntity, SonosPollingEntity +from .favorites import SonosFavorites from .helpers import soco_error from .speaker import SonosSpeaker @@ -40,6 +47,16 @@ async def async_setup_entry( entity = SonosBatteryEntity(speaker) async_add_entities([entity]) + @callback + def _async_create_favorites_sensor(favorites: SonosFavorites) -> None: + _LOGGER.debug( + "Creating favorites sensor (%s items) for household %s", + favorites.count, + favorites.household_id, + ) + entity = SonosFavoritesEntity(favorites) + async_add_entities([entity]) + config_entry.async_on_unload( async_dispatcher_connect( hass, SONOS_CREATE_AUDIO_FORMAT_SENSOR, _async_create_audio_format_entity @@ -51,6 +68,12 @@ async def async_setup_entry( ) ) + config_entry.async_on_unload( + async_dispatcher_connect( + hass, SONOS_CREATE_FAVORITES_SENSOR, _async_create_favorites_sensor + ) + ) + class SonosBatteryEntity(SonosEntity, SensorEntity): """Representation of a Sonos Battery entity.""" @@ -107,3 +130,36 @@ class SonosAudioInputFormatSensorEntity(SonosPollingEntity, SensorEntity): async def _async_fallback_poll(self) -> None: """Provide a stub for required ABC method.""" + + +class SonosFavoritesEntity(SensorEntity): + """Representation of a Sonos favorites info entity.""" + + _attr_entity_registry_enabled_default = False + _attr_icon = "mdi:star" + _attr_name = "Sonos Favorites" + _attr_native_unit_of_measurement = "items" + _attr_should_poll = False + + def __init__(self, favorites: SonosFavorites) -> None: + """Initialize the favorites sensor.""" + self.favorites = favorites + self._attr_unique_id = f"{favorites.household_id}-favorites" + + async def async_added_to_hass(self) -> None: + """Handle common setup when added to hass.""" + await self._async_update_state() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SONOS_FAVORITES_UPDATED}-{self.favorites.household_id}", + self._async_update_state, + ) + ) + + async def _async_update_state(self) -> None: + self._attr_native_value = self.favorites.count + self._attr_extra_state_attributes = { + "items": {fav.item_id: fav.title for fav in self.favorites} + } + self.async_write_ha_state() diff --git a/tests/components/sonos/test_sensor.py b/tests/components/sonos/test_sensor.py index 8a51ea5b2e6..49ddbffc41a 100644 --- a/tests/components/sonos/test_sensor.py +++ b/tests/components/sonos/test_sensor.py @@ -1,14 +1,18 @@ """Tests for the Sonos battery sensor platform.""" -from unittest.mock import PropertyMock +from datetime import timedelta +from unittest.mock import PropertyMock, patch from soco.exceptions import NotSupportedException from homeassistant.components.sensor import SCAN_INTERVAL from homeassistant.components.sonos.binary_sensor import ATTR_BATTERY_POWER_SOURCE +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.helpers import entity_registry as ent_reg from homeassistant.util import dt as dt_util +from .conftest import SonosMockEvent + from tests.common import async_fire_time_changed @@ -178,3 +182,38 @@ async def test_microphone_binary_sensor( mic_binary_sensor_state = hass.states.get(mic_binary_sensor.entity_id) assert mic_binary_sensor_state.state == STATE_ON + + +async def test_favorites_sensor(hass, async_autosetup_sonos, soco): + """Test Sonos favorites sensor.""" + entity_registry = ent_reg.async_get(hass) + favorites = entity_registry.entities["sensor.sonos_favorites"] + assert hass.states.get(favorites.entity_id) is None + + # Enable disabled sensor + entity_registry.async_update_entity(entity_id=favorites.entity_id, disabled_by=None) + await hass.async_block_till_done() + + # Fire event to cancel poll timer and avoid triggering errors during time jump + service = soco.contentDirectory + empty_event = SonosMockEvent(soco, service, {}) + subscription = service.subscribe.return_value + subscription.callback(event=empty_event) + await hass.async_block_till_done() + + # Reload the integration to enable the sensor + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + favorites_updated_event = SonosMockEvent( + soco, service, {"favorites_update_id": "2", "container_update_i_ds": "FV:2,2"} + ) + with patch( + "homeassistant.components.sonos.favorites.SonosFavorites.update_cache", + return_value=True, + ): + subscription.callback(event=favorites_updated_event) + await hass.async_block_till_done()