1"""This module contains classes relating to Sonos Alarms."""
2import logging
3import re
4from datetime import datetime
5
6from . import discovery
7from .core import _SocoSingletonBase, PLAY_MODES
8from .exceptions import SoCoException
9from .xml import XML
10
11log = logging.getLogger(__name__)
12TIME_FORMAT = "%H:%M:%S"
13
14
15def is_valid_recurrence(text):
16    """Check that ``text`` is a valid recurrence string.
17
18    A valid recurrence string is  ``DAILY``, ``ONCE``, ``WEEKDAYS``,
19    ``WEEKENDS`` or of the form ``ON_DDDDDD`` where ``D`` is a number from 0-6
20    representing a day of the week (Sunday is 0), e.g. ``ON_034`` meaning
21    Sunday, Wednesday and Thursday
22
23    Args:
24        text (str): the recurrence string to check.
25
26    Returns:
27        bool: `True` if the recurrence string is valid, else `False`.
28
29    Examples:
30
31        >>> from soco.alarms import is_valid_recurrence
32        >>> is_valid_recurrence('WEEKENDS')
33        True
34        >>> is_valid_recurrence('')
35        False
36        >>> is_valid_recurrence('ON_132')  # Mon, Tue, Wed
37        True
38        >>> is_valid_recurrence('ON_666')  # Sat
39        True
40        >>> is_valid_recurrence('ON_3421') # Mon, Tue, Wed, Thur
41        True
42        >>> is_valid_recurrence('ON_123456789') # Too many digits
43        False
44    """
45    if text in ("DAILY", "ONCE", "WEEKDAYS", "WEEKENDS"):
46        return True
47    return re.search(r"^ON_[0-6]{1,7}$", text) is not None
48
49
50class Alarms(_SocoSingletonBase):
51    """A class representing all known Sonos Alarms.
52
53    Is a singleton and every `Alarms()` object will return the same instance.
54
55    Example use:
56
57        >>> get_alarms()
58        {469: <Alarm id:469@22:07:41 at 0x7f5198797dc0>,
59         470: <Alarm id:470@22:07:46 at 0x7f5198797d60>}
60        >>> alarms = Alarms()
61        >>> alarms.update()
62        >>> alarms.alarms
63        {469: <Alarm id:469@22:07:41 at 0x7f5198797dc0>,
64         470: <Alarm id:470@22:07:46 at 0x7f5198797d60>}
65        >>> for alarm in alarms:
66        ...     alarm
67        ...
68        <Alarm id:469@22:07:41 at 0x7f5198797dc0>
69        <Alarm id:470@22:07:46 at 0x7f5198797d60>
70        >>> alarms[470]
71        <Alarm id:470@22:07:46 at 0x7f5198797d60>
72        >>> new_alarm = Alarm(zone)
73        >>> new_alarm.save()
74        471
75        >>> new_alarm.recurrence = "ONCE"
76        >>> new_alarm.save()
77        471
78        >>> alarms.alarms
79        {469: <Alarm id:469@22:07:41 at 0x7f5198797dc0>,
80         470: <Alarm id:470@22:07:46 at 0x7f5198797d60>,
81         471: <Alarm id:471@22:08:40 at 0x7f51987f1b50>}
82        >>> alarms[470].remove()
83        >>> alarms.alarms
84        {469: <Alarm id:469@22:07:41 at 0x7f5198797dc0>,
85         471: <Alarm id:471@22:08:40 at 0x7f51987f1b50>}
86        >>> for alarm in alarms:
87        ...     alarm.remove()
88        ...
89        >>> a.alarms
90        {}
91    """
92
93    _class_group = "Alarms"
94
95    def __init__(self):
96        """Initialize the instance."""
97        self.alarms = {}
98        self._last_zone_used = None
99        self._last_alarm_list_version = None
100        self.last_uid = None
101        self.last_id = 0
102
103    @property
104    def last_alarm_list_version(self):
105        """Return last seen alarm list version."""
106        return self._last_alarm_list_version
107
108    @last_alarm_list_version.setter
109    def last_alarm_list_version(self, alarm_list_version):
110        """Store alarm list version and store UID/ID values."""
111        self.last_uid, last_id = alarm_list_version.split(":")
112        self.last_id = int(last_id)
113        self._last_alarm_list_version = alarm_list_version
114
115    def __iter__(self):
116        """Return an interator for all alarms."""
117        for alarm in list(self.alarms.values()):
118            yield alarm
119
120    def __len__(self):
121        """Return the number of alarms."""
122        return len(self.alarms)
123
124    def __getitem__(self, alarm_id):
125        """Return the alarm by ID."""
126        return self.alarms[alarm_id]
127
128    def get(self, alarm_id):
129        """Return the alarm by ID or None."""
130        return self.alarms.get(alarm_id)
131
132    def update(self, zone=None):
133        """Update all alarms and current alarm list version.
134
135        Raises:
136            SoCoException: If the 'CurrentAlarmListVersion' value is unexpected.
137                May occur if the provided zone is from a different household.
138        """
139        if zone is None:
140            zone = self._last_zone_used or discovery.any_soco()
141
142        self._last_zone_used = zone
143
144        response = zone.alarmClock.ListAlarms()
145        current_alarm_list_version = response["CurrentAlarmListVersion"]
146
147        if self.last_alarm_list_version:
148            alarm_list_uid, alarm_list_id = current_alarm_list_version.split(":")
149            if self.last_uid != alarm_list_uid:
150                matching_zone = next(
151                    (z for z in zone.all_zones if z.uid == alarm_list_uid), None
152                )
153                if not matching_zone:
154                    raise SoCoException(
155                        "Alarm list UID {} does not match {}".format(
156                            current_alarm_list_version, self.last_alarm_list_version
157                        )
158                    )
159
160            if int(alarm_list_id) <= self.last_id:
161                return
162
163        self.last_alarm_list_version = current_alarm_list_version
164
165        new_alarms = parse_alarm_payload(response, zone)
166
167        # Update existing and create new Alarm instances
168        for alarm_id, kwargs in new_alarms.items():
169            existing_alarm = self.alarms.get(alarm_id)
170            if existing_alarm:
171                existing_alarm.update(**kwargs)
172            else:
173                new_alarm = Alarm(**kwargs)
174                new_alarm._alarm_id = alarm_id  # pylint: disable=protected-access
175                self.alarms[alarm_id] = new_alarm
176
177        # Prune alarms removed externally
178        for alarm_id in list(self.alarms):
179            if not new_alarms.get(alarm_id):
180                self.alarms.pop(alarm_id)
181
182
183class Alarm:
184
185    """A class representing a Sonos Alarm.
186
187    Alarms may be created or updated and saved to, or removed from the Sonos
188    system. An alarm is not automatically saved. Call `save()` to do that.
189    """
190
191    # pylint: disable=too-many-instance-attributes
192    # pylint: disable=too-many-arguments
193    def __init__(
194        self,
195        zone,
196        start_time=None,
197        duration=None,
198        recurrence="DAILY",
199        enabled=True,
200        program_uri=None,
201        program_metadata="",
202        play_mode="NORMAL",
203        volume=20,
204        include_linked_zones=False,
205    ):
206        """
207        Args:
208            zone (`SoCo`): The soco instance which will play the alarm.
209            start_time (datetime.time, optional): The alarm's start time.
210                Specify hours, minutes and seconds only. Defaults to the
211                current time.
212            duration (datetime.time, optional): The alarm's duration. Specify
213                hours, minutes and seconds only. May be `None` for unlimited
214                duration. Defaults to `None`.
215            recurrence (str, optional): A string representing how
216                often the alarm should be triggered. Can be ``DAILY``,
217                ``ONCE``, ``WEEKDAYS``, ``WEEKENDS`` or of the form
218                ``ON_DDDDDD`` where ``D`` is a number from 0-6 representing a
219                day of the week (Sunday is 0), e.g. ``ON_034`` meaning Sunday,
220                Wednesday and Thursday. Defaults to ``DAILY``.
221            enabled (bool, optional): `True` if alarm is enabled, `False`
222                otherwise. Defaults to `True`.
223            program_uri(str, optional): The uri to play. If `None`, the
224                built-in Sonos chime sound will be used. Defaults to `None`.
225            program_metadata (str, optional): The metadata associated with
226                'program_uri'. Defaults to ''.
227            play_mode(str, optional): The play mode for the alarm. Can be one
228                of ``NORMAL``, ``SHUFFLE_NOREPEAT``, ``SHUFFLE``,
229                ``REPEAT_ALL``, ``REPEAT_ONE``, ``SHUFFLE_REPEAT_ONE``.
230                Defaults to ``NORMAL``.
231            volume (int, optional): The alarm's volume (0-100). Defaults to 20.
232            include_linked_zones (bool, optional): `True` if the alarm should
233                be played on the other speakers in the same group, `False`
234                otherwise. Defaults to `False`.
235        """
236
237        self.zone = zone
238        if start_time is None:
239            start_time = datetime.now().time().replace(microsecond=0)
240        self.start_time = start_time
241        self.duration = duration
242        self.recurrence = recurrence
243        self.enabled = enabled
244        self.program_uri = program_uri
245        self.program_metadata = program_metadata
246        self.play_mode = play_mode
247        self.volume = volume
248        self.include_linked_zones = include_linked_zones
249        self._alarm_id = None
250
251    def __repr__(self):
252        middle = str(self.start_time.strftime(TIME_FORMAT))
253        return "<{} id:{}@{} at {}>".format(
254            self.__class__.__name__, self.alarm_id, middle, hex(id(self))
255        )
256
257    def update(self, **kwargs):
258        """Update an existing Alarm instance using the same arguments as __init__."""
259        for attr, value in kwargs.items():
260            if not hasattr(self, attr):
261                raise SoCoException("Alarm does not have atttribute {}".format(attr))
262            setattr(self, attr, value)
263
264    @property
265    def play_mode(self):
266        """
267        `str`: The play mode for the alarm.
268
269            Can be one of ``NORMAL``, ``SHUFFLE_NOREPEAT``, ``SHUFFLE``,
270            ``REPEAT_ALL``, ``REPEAT_ONE``, ``SHUFFLE_REPEAT_ONE``.
271        """
272        return self._play_mode
273
274    @play_mode.setter
275    def play_mode(self, play_mode):
276        """See `playmode`."""
277        play_mode = play_mode.upper()
278        if play_mode not in PLAY_MODES:
279            raise KeyError("'%s' is not a valid play mode" % play_mode)
280        self._play_mode = play_mode
281
282    @property
283    def volume(self):
284        """`int`: The alarm's volume (0-100)."""
285        return self._volume
286
287    @volume.setter
288    def volume(self, volume):
289        """See `volume`."""
290        # max 100
291        volume = int(volume)
292        self._volume = max(0, min(volume, 100))  # Coerce in range
293
294    @property
295    def recurrence(self):
296        """`str`: How often the alarm should be triggered.
297
298        Can be ``DAILY``, ``ONCE``, ``WEEKDAYS``, ``WEEKENDS`` or of the form
299        ``ON_DDDDDDD`` where ``D`` is a number from 0-7 representing a day of
300        the week (Sunday is 0), e.g. ``ON_034`` meaning Sunday, Wednesday and
301        Thursday.
302        """
303        return self._recurrence
304
305    @recurrence.setter
306    def recurrence(self, recurrence):
307        """See `recurrence`."""
308        if not is_valid_recurrence(recurrence):
309            raise KeyError("'%s' is not a valid recurrence value" % recurrence)
310
311        self._recurrence = recurrence
312
313    def save(self):
314        """Save the alarm to the Sonos system.
315
316        Returns:
317            str: The alarm ID, or `None` if no alarm was saved.
318
319        Raises:
320            ~soco.exceptions.SoCoUPnPException: if the alarm cannot be created
321                because there
322                is already an alarm for this room at the specified time.
323        """
324        args = [
325            ("StartLocalTime", self.start_time.strftime(TIME_FORMAT)),
326            (
327                "Duration",
328                "" if self.duration is None else self.duration.strftime(TIME_FORMAT),
329            ),
330            ("Recurrence", self.recurrence),
331            ("Enabled", "1" if self.enabled else "0"),
332            ("RoomUUID", self.zone.uid),
333            (
334                "ProgramURI",
335                "x-rincon-buzzer:0" if self.program_uri is None else self.program_uri,
336            ),
337            ("ProgramMetaData", self.program_metadata),
338            ("PlayMode", self.play_mode),
339            ("Volume", self.volume),
340            ("IncludeLinkedZones", "1" if self.include_linked_zones else "0"),
341        ]
342        if self.alarm_id is None:
343            response = self.zone.alarmClock.CreateAlarm(args)
344            self._alarm_id = response["AssignedID"]
345            alarms = Alarms()
346            if alarms.last_id == int(self.alarm_id) - 1:
347                alarms.last_alarm_list_version = "{}:{}".format(
348                    alarms.last_uid, self.alarm_id
349                )
350            alarms.alarms[self.alarm_id] = self
351        else:
352            # The alarm has been saved before. Update it instead.
353            args.insert(0, ("ID", self.alarm_id))
354            self.zone.alarmClock.UpdateAlarm(args)
355        return self.alarm_id
356
357    def remove(self):
358        """Remove the alarm from the Sonos system.
359
360        There is no need to call `save`. The Python instance is not deleted,
361        and can be saved back to Sonos again if desired.
362
363        Returns:
364            bool: If the removal was sucessful.
365        """
366        result = self.zone.alarmClock.DestroyAlarm([("ID", self.alarm_id)])
367        alarms = Alarms()
368        alarms.alarms.pop(self.alarm_id, None)
369        self._alarm_id = None
370        return result
371
372    @property
373    def alarm_id(self):
374        """`str`: The ID of the alarm, or `None`."""
375        return self._alarm_id
376
377
378def get_alarms(zone=None):
379    """Get a set of all alarms known to the Sonos system.
380
381    Args:
382        zone (soco.SoCo, optional): a SoCo instance to query. If None, a random
383            instance is used. Defaults to `None`.
384
385    Returns:
386        set: A set of `Alarm` instances
387    """
388    alarms = Alarms()
389    alarms.update(zone)
390    return set(alarms.alarms.values())
391
392
393def remove_alarm_by_id(zone, alarm_id):
394    """Remove an alarm from the Sonos system by its ID.
395
396    Args:
397        zone (`SoCo`): A SoCo instance, which can be any zone that belongs
398            to the Sonos system in which the required alarm is defined.
399        alarm_id (str): The ID of the alarm to be removed.
400
401    Returns:
402        bool: `True` if the alarm is found and removed, `False` otherwise.
403    """
404    alarms = Alarms()
405    alarms.update(zone)
406    alarm = alarms.get(alarm_id)
407    if not alarm:
408        return False
409    return alarm.remove()
410
411
412def parse_alarm_payload(payload, zone):
413    """Parse the XML payload response and return a dict of `Alarm` kwargs."""
414    alarm_list = payload["CurrentAlarmList"]
415    tree = XML.fromstring(alarm_list.encode("utf-8"))
416
417    # An alarm list looks like this:
418    # <Alarms>
419    #     <Alarm ID="14" StartTime="07:00:00"
420    #         Duration="02:00:00" Recurrence="DAILY" Enabled="1"
421    #         RoomUUID="RINCON_000ZZZZZZ1400"
422    #         ProgramURI="x-rincon-buzzer:0" ProgramMetaData=""
423    #         PlayMode="SHUFFLE_NOREPEAT" Volume="25"
424    #         IncludeLinkedZones="0"/>
425    #     <Alarm ID="15" StartTime="07:00:00"
426    #         Duration="02:00:00" Recurrence="DAILY" Enabled="1"
427    #         RoomUUID="RINCON_000ZZZZZZ01400"
428    #         ProgramURI="x-rincon-buzzer:0" ProgramMetaData=""
429    #         PlayMode="SHUFFLE_NOREPEAT" Volume="25"
430    #          IncludeLinkedZones="0"/>
431    # </Alarms>
432
433    alarms = tree.findall("Alarm")
434    alarm_args = {}
435    for alarm in alarms:
436        values = alarm.attrib
437        alarm_id = values["ID"]
438
439        alarm_zone = next(
440            (z for z in zone.all_zones if z.uid == values["RoomUUID"]), None
441        )
442        if alarm_zone is None:
443            # Some alarms are not associated with a zone, ignore these
444            continue
445
446        args = {
447            "zone": alarm_zone,
448            # StartTime not StartLocalTime which is used by CreateAlarm
449            "start_time": datetime.strptime(values["StartTime"], "%H:%M:%S").time(),
450            "duration": (
451                None
452                if values["Duration"] == ""
453                else datetime.strptime(values["Duration"], "%H:%M:%S").time()
454            ),
455            "recurrence": values["Recurrence"],
456            "enabled": values["Enabled"] == "1",
457            "program_uri": (
458                None
459                if values["ProgramURI"] == "x-rincon-buzzer:0"
460                else values["ProgramURI"]
461            ),
462            "program_metadata": values["ProgramMetaData"],
463            "play_mode": values["PlayMode"],
464            "volume": values["Volume"],
465            "include_linked_zones": values["IncludeLinkedZones"] == "1",
466        }
467
468        alarm_args[alarm_id] = args
469    return alarm_args
470