Source code for soco.alarms

"""This module contains classes relating to Sonos Alarms."""

import logging
import re
import weakref
from datetime import datetime

from . import discovery
from .core import PLAY_MODES
from .xml import XML

log = logging.getLogger(__name__)  # pylint: disable=C0103
TIME_FORMAT = "%H:%M:%S"


[docs]def is_valid_recurrence(text): """Check that ``text`` is a valid recurrence string. A valid recurrence string is ``DAILY``, ``ONCE``, ``WEEKDAYS``, ``WEEKENDS`` or of the form ``ON_DDDDDD`` where ``D`` is a number from 0-6 representing a day of the week (Sunday is 0), e.g. ``ON_034`` meaning Sunday, Wednesday and Thursday Args: text (str): the recurrence string to check. Returns: bool: `True` if the recurrence string is valid, else `False`. Examples: >>> from soco.alarms import is_valid_recurrence >>> is_valid_recurrence('WEEKENDS') True >>> is_valid_recurrence('') False >>> is_valid_recurrence('ON_132') # Mon, Tue, Wed True >>> is_valid_recurrence('ON_666') # Sat True >>> is_valid_recurrence('ON_3421') # Mon, Tue, Wed, Thur True >>> is_valid_recurrence('ON_123456789') # Too many digits False """ if text in ("DAILY", "ONCE", "WEEKDAYS", "WEEKENDS"): return True return re.search(r"^ON_[0-6]{1,7}$", text) is not None
[docs]class Alarm: """A class representing a Sonos Alarm. Alarms may be created or updated and saved to, or removed from the Sonos system. An alarm is not automatically saved. Call `save()` to do that. Example: >>> device = discovery.any_soco() >>> # create an alarm with default properties >>> alarm = Alarm(device) >>> print alarm.volume 20 >>> print get_alarms() set([]) >>> # save the alarm to the Sonos system >>> alarm.save() >>> print get_alarms() set([<Alarm id:88@15:26:15 at 0x107abb090>]) >>> # update the alarm >>> alarm.recurrence = "ONCE" >>> # Save it again for the change to take effect >>> alarm.save() >>> # Remove it >>> alarm.remove() >>> print get_alarms() set([]) """ # pylint: disable=too-many-instance-attributes _all_alarms = weakref.WeakValueDictionary() # pylint: disable=too-many-arguments def __init__( self, zone, start_time=None, duration=None, recurrence="DAILY", enabled=True, program_uri=None, program_metadata="", play_mode="NORMAL", volume=20, include_linked_zones=False, ): """ Args: zone (`SoCo`): The soco instance which will play the alarm. start_time (datetime.time, optional): The alarm's start time. Specify hours, minutes and seconds only. Defaults to the current time. duration (datetime.time, optional): The alarm's duration. Specify hours, minutes and seconds only. May be `None` for unlimited duration. Defaults to `None`. recurrence (str, optional): A string representing how often the alarm should be triggered. Can be ``DAILY``, ``ONCE``, ``WEEKDAYS``, ``WEEKENDS`` or of the form ``ON_DDDDDD`` where ``D`` is a number from 0-6 representing a day of the week (Sunday is 0), e.g. ``ON_034`` meaning Sunday, Wednesday and Thursday. Defaults to ``DAILY``. enabled (bool, optional): `True` if alarm is enabled, `False` otherwise. Defaults to `True`. program_uri(str, optional): The uri to play. If `None`, the built-in Sonos chime sound will be used. Defaults to `None`. program_metadata (str, optional): The metadata associated with `program_uri`. Defaults to ''. play_mode(str, optional): The play mode for the alarm. Can be one of ``NORMAL``, ``SHUFFLE_NOREPEAT``, ``SHUFFLE``, ``REPEAT_ALL``, ``REPEAT_ONE``, ``SHUFFLE_REPEAT_ONE``. Defaults to ``NORMAL``. volume (int, optional): The alarm's volume (0-100). Defaults to 20. include_linked_zones (bool, optional): `True` if the alarm should be played on the other speakers in the same group, `False` otherwise. Defaults to `False`. """ super().__init__() self.zone = zone if start_time is None: start_time = datetime.now().time() #: `datetime.time`: The alarm's start time. self.start_time = start_time #: `datetime.time`: The alarm's duration. self.duration = duration self._recurrence = recurrence #: `bool`: `True` if the alarm is enabled, else `False`. self.enabled = enabled #: self.program_uri = program_uri #: `str`: The uri to play. self.program_metadata = program_metadata self._play_mode = play_mode self._volume = volume #: `bool`: `True` if the alarm should be played on the other speakers #: in the same group, `False` otherwise. self.include_linked_zones = include_linked_zones self._alarm_id = None def __repr__(self): middle = str(self.start_time.strftime(TIME_FORMAT)) return "<{} id:{}@{} at {}>".format( self.__class__.__name__, self._alarm_id, middle, hex(id(self)) ) @property def play_mode(self): """ `str`: The play mode for the alarm. Can be one of ``NORMAL``, ``SHUFFLE_NOREPEAT``, ``SHUFFLE``, ``REPEAT_ALL``, ``REPEAT_ONE``, ``SHUFFLE_REPEAT_ONE``. """ return self._play_mode @play_mode.setter def play_mode(self, play_mode): """See `playmode`.""" play_mode = play_mode.upper() if play_mode not in PLAY_MODES: raise KeyError("'%s' is not a valid play mode" % play_mode) self._play_mode = play_mode @property def volume(self): """`int`: The alarm's volume (0-100).""" return self._volume @volume.setter def volume(self, volume): """See `volume`.""" # max 100 volume = int(volume) self._volume = max(0, min(volume, 100)) # Coerce in range @property def recurrence(self): """`str`: How often the alarm should be triggered. Can be ``DAILY``, ``ONCE``, ``WEEKDAYS``, ``WEEKENDS`` or of the form ``ON_DDDDDDD`` where ``D`` is a number from 0-7 representing a day of the week (Sunday is 0), e.g. ``ON_034`` meaning Sunday, Wednesday and Thursday. """ return self._recurrence @recurrence.setter def recurrence(self, recurrence): """See `recurrence`.""" if not is_valid_recurrence(recurrence): raise KeyError("'%s' is not a valid recurrence value" % recurrence) self._recurrence = recurrence
[docs] def save(self): """Save the alarm to the Sonos system. Returns: str: The alarm ID, or `None` if no alarm was saved. Raises: ~soco.exceptions.SoCoUPnPException: if the alarm cannot be created because there is already an alarm for this room at the specified time. """ # pylint: disable=bad-continuation args = [ ("StartLocalTime", self.start_time.strftime(TIME_FORMAT)), ( "Duration", "" if self.duration is None else self.duration.strftime(TIME_FORMAT), ), ("Recurrence", self.recurrence), ("Enabled", "1" if self.enabled else "0"), ("RoomUUID", self.zone.uid), ( "ProgramURI", "x-rincon-buzzer:0" if self.program_uri is None else self.program_uri, ), ("ProgramMetaData", self.program_metadata), ("PlayMode", self.play_mode), ("Volume", self.volume), ("IncludeLinkedZones", "1" if self.include_linked_zones else "0"), ] if self._alarm_id is None: response = self.zone.alarmClock.CreateAlarm(args) self._alarm_id = response["AssignedID"] Alarm._all_alarms[self._alarm_id] = self else: # The alarm has been saved before. Update it instead. args.insert(0, ("ID", self._alarm_id)) self.zone.alarmClock.UpdateAlarm(args) return self._alarm_id
[docs] def remove(self): """Remove the alarm from the Sonos system. There is no need to call `save`. The Python instance is not deleted, and can be saved back to Sonos again if desired. """ self.zone.alarmClock.DestroyAlarm([("ID", self._alarm_id)]) alarm_id = self._alarm_id try: del Alarm._all_alarms[alarm_id] except KeyError: pass self._alarm_id = None
@property def alarm_id(self): """`str`: The ID of the alarm, or `None`.""" return self._alarm_id
[docs]def get_alarms(zone=None): """Get a set of all alarms known to the Sonos system. Args: zone (soco.SoCo, optional): a SoCo instance to query. If None, a random instance is used. Defaults to `None`. Returns: set: A set of `Alarm` instances Note: Any existing `Alarm` instance will have its attributes updated to those currently stored on the Sonos system. """ # Get a soco instance to query. It doesn't matter which. if zone is None: zone = discovery.any_soco() response = zone.alarmClock.ListAlarms() alarm_list = response["CurrentAlarmList"] tree = XML.fromstring(alarm_list.encode("utf-8")) # An alarm list looks like this: # <Alarms> # <Alarm ID="14" StartTime="07:00:00" # Duration="02:00:00" Recurrence="DAILY" Enabled="1" # RoomUUID="RINCON_000ZZZZZZ1400" # ProgramURI="x-rincon-buzzer:0" ProgramMetaData="" # PlayMode="SHUFFLE_NOREPEAT" Volume="25" # IncludeLinkedZones="0"/> # <Alarm ID="15" StartTime="07:00:00" # Duration="02:00:00" Recurrence="DAILY" Enabled="1" # RoomUUID="RINCON_000ZZZZZZ01400" # ProgramURI="x-rincon-buzzer:0" ProgramMetaData="" # PlayMode="SHUFFLE_NOREPEAT" Volume="25" # IncludeLinkedZones="0"/> # </Alarms> # pylint: disable=protected-access alarms = tree.findall("Alarm") result = set() for alarm in alarms: values = alarm.attrib alarm_id = values["ID"] # If an instance already exists for this ID, update and return it. # Otherwise, create a new one and populate its values if Alarm._all_alarms.get(alarm_id): instance = Alarm._all_alarms.get(alarm_id) else: instance = Alarm(None) instance._alarm_id = alarm_id Alarm._all_alarms[instance._alarm_id] = instance instance.start_time = datetime.strptime( values["StartTime"], "%H:%M:%S" ).time() # NB StartTime, not # StartLocalTime, which is used by CreateAlarm instance.duration = ( None if values["Duration"] == "" else datetime.strptime(values["Duration"], "%H:%M:%S").time() ) instance.recurrence = values["Recurrence"] instance.enabled = values["Enabled"] == "1" instance.zone = next( (z for z in zone.all_zones if z.uid == values["RoomUUID"]), None ) # some alarms are not associated to zones -> filter these out if instance.zone is None: continue instance.program_uri = ( None if values["ProgramURI"] == "x-rincon-buzzer:0" else values["ProgramURI"] ) instance.program_metadata = values["ProgramMetaData"] instance.play_mode = values["PlayMode"] instance.volume = values["Volume"] instance.include_linked_zones = values["IncludeLinkedZones"] == "1" result.add(instance) return result
[docs]def remove_alarm_by_id(zone, alarm_id): """Remove an alarm from the Sonos system by its ID. Args: zone (`SoCo`): A SoCo instance, which can be any zone that belongs to the Sonos system in which the required alarm is defined. alarm_id (str): The ID of the alarm to be removed. Returns: bool: `True` if the alarm is found and removed, `False` otherwise. """ alarms = get_alarms(zone) for alarm in alarms: if alarm.alarm_id == alarm_id: alarm.remove() return True return False