diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index c513a73b6e8..a904ae58db6 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -9,6 +9,7 @@ import socket import pysonos from pysonos import events_asyncio +from pysonos.alarms import Alarm from pysonos.core import SoCo from pysonos.exceptions import SoCoException import voluptuous as vol @@ -30,6 +31,7 @@ from .const import ( DISCOVERY_INTERVAL, DOMAIN, PLATFORMS, + SONOS_ALARM_UPDATE, SONOS_GROUP_UPDATE, SONOS_SEEN, ) @@ -70,6 +72,7 @@ class SonosData: # OrderedDict behavior used by SonosFavorites self.discovered: OrderedDict[str, SonosSpeaker] = OrderedDict() self.favorites: dict[str, SonosFavorites] = {} + self.alarms: dict[str, Alarm] = {} self.topology_condition = asyncio.Condition() self.discovery_thread = None self.hosts_heartbeat = None @@ -174,6 +177,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def _async_signal_update_groups(event): async_dispatcher_send(hass, SONOS_GROUP_UPDATE) + @callback + def _async_signal_update_alarms(event): + async_dispatcher_send(hass, SONOS_ALARM_UPDATE) + async def setup_platforms_and_discovery(): await asyncio.gather( *[ @@ -189,6 +196,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: EVENT_HOMEASSISTANT_START, _async_signal_update_groups ) ) + entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, _async_signal_update_alarms + ) + ) _LOGGER.debug("Adding discovery job") await hass.async_add_executor_job(_discovery) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index e14024c32d2..c32f981e345 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -20,10 +20,11 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_TRACK, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN DOMAIN = "sonos" DATA_SONOS = "sonos_media_player" -PLATFORMS = {BINARY_SENSOR_DOMAIN, MP_DOMAIN, SENSOR_DOMAIN} +PLATFORMS = {BINARY_SENSOR_DOMAIN, MP_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN} SONOS_ARTIST = "artists" SONOS_ALBUM = "albums" @@ -131,12 +132,14 @@ PLAYABLE_MEDIA_TYPES = [ MEDIA_TYPE_TRACK, ] +SONOS_CREATE_ALARM = "sonos_create_alarm" SONOS_CREATE_BATTERY = "sonos_create_battery" SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player" SONOS_ENTITY_CREATED = "sonos_entity_created" SONOS_ENTITY_UPDATE = "sonos_entity_update" SONOS_GROUP_UPDATE = "sonos_group_update" SONOS_HOUSEHOLD_UPDATED = "sonos_household_updated" +SONOS_ALARM_UPDATE = "sonos_alarm_update" SONOS_STATE_UPDATED = "sonos_state_updated" SONOS_SEEN = "sonos_seen" diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 06b9c49257a..9ca4b21425b 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -588,8 +588,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """Set the alarm clock on the player.""" alarm = None for one_alarm in alarms.get_alarms(self.coordinator.soco): - # pylint: disable=protected-access - if one_alarm._alarm_id == str(alarm_id): + if one_alarm.alarm_id == str(alarm_id): alarm = one_alarm if alarm is None: _LOGGER.warning("Did not find alarm with id %s", alarm_id) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 97fc8dcdbcc..708b29d5c55 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -11,6 +11,7 @@ from typing import Any, Callable import urllib.parse import async_timeout +from pysonos.alarms import get_alarms from pysonos.core import MUSIC_SRC_LINE_IN, MUSIC_SRC_RADIO, MUSIC_SRC_TV, SoCo from pysonos.data_structures import DidlAudioBroadcast from pysonos.events_base import Event as SonosEvent, SubscriptionBase @@ -21,6 +22,7 @@ from pysonos.snapshot import Snapshot from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as ent_reg from homeassistant.helpers.dispatcher import ( @@ -37,6 +39,8 @@ from .const import ( PLATFORMS, SCAN_INTERVAL, SEEN_EXPIRE_TIME, + SONOS_ALARM_UPDATE, + SONOS_CREATE_ALARM, SONOS_CREATE_BATTERY, SONOS_CREATE_MEDIA_PLAYER, SONOS_ENTITY_CREATED, @@ -193,12 +197,17 @@ class SonosSpeaker: else: self._platforms_ready.update({BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN}) + if new_alarms := self.update_alarms_for_speaker(): + dispatcher_send(self.hass, SONOS_CREATE_ALARM, self, new_alarms) + else: + self._platforms_ready.add(SWITCH_DOMAIN) + dispatcher_send(self.hass, SONOS_CREATE_MEDIA_PLAYER, self) async def async_handle_new_entity(self, entity_type: str) -> None: """Listen to new entities to trigger first subscription.""" self._platforms_ready.add(entity_type) - if self._platforms_ready == PLATFORMS: + if self._platforms_ready == PLATFORMS and not self._subscriptions: self._resubscription_lock = asyncio.Lock() await self.async_subscribe() self._is_ready = True @@ -244,6 +253,7 @@ class SonosSpeaker: self._subscribe( self.soco.deviceProperties, self.async_dispatch_properties ), + self._subscribe(self.soco.alarmClock, self.async_dispatch_alarms), ) return True except SoCoException as ex: @@ -266,6 +276,11 @@ class SonosSpeaker: """Update properties from event.""" self.hass.async_create_task(self.async_update_device_properties(event)) + @callback + def async_dispatch_alarms(self, event: SonosEvent | None = None) -> None: + """Update alarms from event.""" + self.hass.async_create_task(self.async_update_alarms(event)) + @callback def async_dispatch_groups(self, event: SonosEvent | None = None) -> None: """Update groups from event.""" @@ -365,6 +380,42 @@ class SonosSpeaker: self.async_write_entity_states() + def update_alarms_for_speaker(self) -> set[str]: + """Update current alarm instances. + + Updates hass.data[DATA_SONOS].alarms and returns a list of all alarms that are new. + """ + new_alarms = set() + stored_alarms = self.hass.data[DATA_SONOS].alarms + updated_alarms = get_alarms(self.soco) + + for alarm in updated_alarms: + if alarm.zone.uid == self.soco.uid and alarm.alarm_id not in list( + stored_alarms.keys() + ): + new_alarms.add(alarm.alarm_id) + stored_alarms[alarm.alarm_id] = alarm + + for alarm_id, alarm in list(stored_alarms.items()): + if alarm not in updated_alarms: + stored_alarms.pop(alarm_id) + + return new_alarms + + async def async_update_alarms(self, event: SonosEvent | None = None) -> None: + """Update device properties using the provided SonosEvent.""" + if event is None: + return + + if new_alarms := await self.hass.async_add_executor_job( + self.update_alarms_for_speaker + ): + async_dispatcher_send(self.hass, SONOS_CREATE_ALARM, self, new_alarms) + + async_dispatcher_send(self.hass, SONOS_ALARM_UPDATE, self) + + self.async_write_entity_states() + async def async_update_battery_info(self, battery_dict: dict[str, Any]) -> None: """Update battery info using the decoded SonosEvent.""" self._last_battery_event = dt_util.utcnow() diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py new file mode 100644 index 00000000000..967bc21da59 --- /dev/null +++ b/homeassistant/components/sonos/switch.py @@ -0,0 +1,202 @@ +"""Entity representing a Sonos Alarm.""" +from __future__ import annotations + +import datetime +import logging + +from pysonos.exceptions import SoCoUPnPException + +from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity +from homeassistant.const import ATTR_TIME +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ( + DATA_SONOS, + DOMAIN as SONOS_DOMAIN, + SONOS_ALARM_UPDATE, + SONOS_CREATE_ALARM, +) +from .entity import SonosEntity +from .speaker import SonosSpeaker + +_LOGGER = logging.getLogger(__name__) + +ATTR_DURATION = "duration" +ATTR_ID = "alarm_id" +ATTR_PLAY_MODE = "play_mode" +ATTR_RECURRENCE = "recurrence" +ATTR_SCHEDULED_TODAY = "scheduled_today" +ATTR_VOLUME = "volume" +ATTR_INCLUDE_LINKED_ZONES = "include_linked_zones" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Sonos from a config entry.""" + + configured_alarms = set() + + async def _async_create_entity(speaker: SonosSpeaker, new_alarms: set) -> None: + for alarm_id in new_alarms: + if alarm_id not in configured_alarms: + _LOGGER.debug("Creating alarm with id %s", alarm_id) + entity = SonosAlarmEntity(alarm_id, speaker) + async_add_entities([entity]) + configured_alarms.add(alarm_id) + config_entry.async_on_unload( + async_dispatcher_connect( + hass, SONOS_ALARM_UPDATE, entity.async_update + ) + ) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SONOS_CREATE_ALARM, _async_create_entity) + ) + + +class SonosAlarmEntity(SonosEntity, SwitchEntity): + """Representation of a Sonos Alarm entity.""" + + def __init__(self, alarm_id: str, speaker: SonosSpeaker) -> None: + """Initialize the switch.""" + super().__init__(speaker) + + self._alarm_id = alarm_id + self.entity_id = ENTITY_ID_FORMAT.format(f"sonos_alarm_{self.alarm_id}") + + @property + def alarm(self): + """Return the ID of the alarm.""" + return self.hass.data[DATA_SONOS].alarms[self.alarm_id] + + @property + def alarm_id(self): + """Return the ID of the alarm.""" + return self._alarm_id + + @property + def unique_id(self) -> str: + """Return the unique ID of the switch.""" + return f"{SONOS_DOMAIN}-{self.alarm_id}" + + @property + def icon(self): + """Return icon of Sonos alarm switch.""" + return "mdi:alarm" + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return "Sonos Alarm {} {} {}".format( + self.speaker.zone_name, + self.alarm.recurrence.title(), + str(self.alarm.start_time)[0:5], + ) + + async def async_check_if_available(self): + """Check if alarm exists and remove alarm entity if not available.""" + if self.alarm_id in self.hass.data[DATA_SONOS].alarms: + return True + + _LOGGER.debug("The alarm is removed from hass because it has been deleted") + + entity_registry = er.async_get(self.hass) + if entity_registry.async_get(self.entity_id): + entity_registry.async_remove(self.entity_id) + + return False + + async def async_update(self, now: datetime.datetime | None = None) -> None: + """Poll the device for the current state.""" + if await self.async_check_if_available(): + await self.hass.async_add_executor_job(self.update_alarm) + + def update_alarm(self): + """Update the state of the alarm.""" + _LOGGER.debug("Updating the state of the alarm") + if self.speaker.soco.uid != self.alarm.zone.uid: + self.speaker = self.hass.data[DATA_SONOS].discovered.get( + self.alarm.zone.uid + ) + if self.speaker is None: + raise RuntimeError( + "No configured Sonos speaker has been found to match the alarm." + ) + + self._update_device() + + self.schedule_update_ha_state() + + def _update_device(self): + """Update the device, since this alarm moved to a different player.""" + device_registry = dr.async_get(self.hass) + entity_registry = er.async_get(self.hass) + entity = entity_registry.async_get(self.entity_id) + + if entity is None: + raise RuntimeError("Alarm has been deleted by accident.") + + entry_id = entity.config_entry_id + + new_device = device_registry.async_get_or_create( + config_entry_id=entry_id, + identifiers={(SONOS_DOMAIN, self.soco.uid)}, + connections={(dr.CONNECTION_NETWORK_MAC, self.speaker.mac_address)}, + ) + if not entity_registry.async_get(self.entity_id).device_id == new_device.id: + _LOGGER.debug("The alarm is switching the sonos player") + # pylint: disable=protected-access + entity_registry._async_update_entity( + self.entity_id, device_id=new_device.id + ) + + @property + def _is_today(self): + recurrence = self.alarm.recurrence + timestr = int(datetime.datetime.today().strftime("%w")) + return ( + bool(recurrence[:2] == "ON" and str(timestr) in recurrence) + or bool(recurrence == "DAILY") + or bool(recurrence == "WEEKDAYS" and int(timestr) not in [0, 7]) + or bool(recurrence == "ONCE") + or bool(recurrence == "WEEKDAYS" and int(timestr) not in [0, 7]) + or bool(recurrence == "WEEKENDS" and int(timestr) not in range(1, 7)) + ) + + @property + def is_on(self): + """Return state of Sonos alarm switch.""" + return self.alarm.enabled + + @property + def extra_state_attributes(self): + """Return attributes of Sonos alarm switch.""" + return { + ATTR_ID: str(self.alarm_id), + ATTR_TIME: str(self.alarm.start_time), + ATTR_DURATION: str(self.alarm.duration), + ATTR_RECURRENCE: str(self.alarm.recurrence), + ATTR_VOLUME: self.alarm.volume / 100, + ATTR_PLAY_MODE: str(self.alarm.play_mode), + ATTR_SCHEDULED_TODAY: self._is_today, + ATTR_INCLUDE_LINKED_ZONES: self.alarm.include_linked_zones, + } + + async def async_turn_on(self, **kwargs) -> None: + """Turn alarm switch on.""" + await self.async_handle_switch_on_off(turn_on=True) + + async def async_turn_off(self, **kwargs) -> None: + """Turn alarm switch off.""" + await self.async_handle_switch_on_off(turn_on=False) + + async def async_handle_switch_on_off(self, turn_on: bool) -> None: + """Handle turn on/off of alarm switch.""" + try: + _LOGGER.debug("Switching the state of the alarm") + self.alarm.enabled = turn_on + await self.hass.async_add_executor_job(self.alarm.save) + except SoCoUPnPException as exc: + _LOGGER.warning( + "Home Assistant couldn't switch the alarm %s", exc, exc_info=True + ) diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 79e44720591..e7e4c42d64c 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -17,7 +17,9 @@ def config_entry_fixture(): @pytest.fixture(name="soco") -def soco_fixture(music_library, speaker_info, battery_info, dummy_soco_service): +def soco_fixture( + music_library, speaker_info, battery_info, dummy_soco_service, alarmClock +): """Create a mock pysonos SoCo fixture.""" with patch("pysonos.SoCo", autospec=True) as mock, patch( "socket.gethostbyname", return_value="192.168.42.2" @@ -32,12 +34,13 @@ def soco_fixture(music_library, speaker_info, battery_info, dummy_soco_service): mock_soco.zoneGroupTopology = dummy_soco_service mock_soco.contentDirectory = dummy_soco_service mock_soco.deviceProperties = dummy_soco_service + mock_soco.alarmClock = alarmClock mock_soco.mute = False mock_soco.night_mode = True mock_soco.dialog_mode = True mock_soco.volume = 19 mock_soco.get_battery_info.return_value = battery_info - + mock_soco.all_zones = [mock_soco] yield mock_soco @@ -75,6 +78,26 @@ def music_library_fixture(): return music_library +@pytest.fixture(name="alarmClock") +def alarmClock_fixture(): + """Create alarmClock fixture.""" + alarmClock = Mock() + alarmClock.subscribe = AsyncMock() + alarmClock.ListAlarms.return_value = { + "CurrentAlarmList": "" + '' + '' + " " + } + return alarmClock + + @pytest.fixture(name="speaker_info") def speaker_info_fixture(): """Create speaker_info fixture.""" diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py new file mode 100644 index 00000000000..c33c472ee27 --- /dev/null +++ b/tests/components/sonos/test_switch.py @@ -0,0 +1,47 @@ +"""Tests for the Sonos Alarm switch platform.""" +from homeassistant.components.sonos import DOMAIN +from homeassistant.components.sonos.switch import ( + ATTR_DURATION, + ATTR_ID, + ATTR_INCLUDE_LINKED_ZONES, + ATTR_PLAY_MODE, + ATTR_RECURRENCE, + ATTR_VOLUME, +) +from homeassistant.const import ATTR_TIME, STATE_ON +from homeassistant.setup import async_setup_component + + +async def setup_platform(hass, config_entry, config): + """Set up the media player platform for testing.""" + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + +async def test_entity_registry(hass, config_entry, config, soco): + """Test sonos device with alarm registered in the device registry.""" + await setup_platform(hass, config_entry, config) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + assert "media_player.zone_a" in entity_registry.entities + assert "switch.sonos_alarm_14" in entity_registry.entities + + +async def test_alarm_attributes(hass, config_entry, config, soco): + """Test for correct sonos alarm state.""" + await setup_platform(hass, config_entry, config) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + alarm = entity_registry.entities["switch.sonos_alarm_14"] + alarm_state = hass.states.get(alarm.entity_id) + assert alarm_state.state == STATE_ON + assert alarm_state.attributes.get(ATTR_TIME) == "07:00:00" + assert alarm_state.attributes.get(ATTR_ID) == "14" + assert alarm_state.attributes.get(ATTR_DURATION) == "02:00:00" + assert alarm_state.attributes.get(ATTR_RECURRENCE) == "DAILY" + assert alarm_state.attributes.get(ATTR_VOLUME) == 0.25 + assert alarm_state.attributes.get(ATTR_PLAY_MODE) == "SHUFFLE_NOREPEAT" + assert not alarm_state.attributes.get(ATTR_INCLUDE_LINKED_ZONES)