"""This module contains classes relating to Sonos Alarms."""
import logging
import re
from datetime import datetime
from . import discovery
from .core import _SocoSingletonBase, PLAY_MODES
from .exceptions import SoCoException
from .xml import XML
log = logging.getLogger(__name__)
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 Alarms(_SocoSingletonBase):
"""A class representing all known Sonos Alarms.
Is a singleton and every `Alarms()` object will return the same instance.
Example use:
>>> get_alarms()
{469: <Alarm id:469@22:07:41 at 0x7f5198797dc0>,
470: <Alarm id:470@22:07:46 at 0x7f5198797d60>}
>>> alarms = Alarms()
>>> alarms.update()
>>> alarms.alarms
{469: <Alarm id:469@22:07:41 at 0x7f5198797dc0>,
470: <Alarm id:470@22:07:46 at 0x7f5198797d60>}
>>> for alarm in alarms:
... alarm
...
<Alarm id:469@22:07:41 at 0x7f5198797dc0>
<Alarm id:470@22:07:46 at 0x7f5198797d60>
>>> alarms[470]
<Alarm id:470@22:07:46 at 0x7f5198797d60>
>>> new_alarm = Alarm(zone)
>>> new_alarm.save()
471
>>> new_alarm.recurrence = "ONCE"
>>> new_alarm.save()
471
>>> alarms.alarms
{469: <Alarm id:469@22:07:41 at 0x7f5198797dc0>,
470: <Alarm id:470@22:07:46 at 0x7f5198797d60>,
471: <Alarm id:471@22:08:40 at 0x7f51987f1b50>}
>>> alarms[470].remove()
>>> alarms.alarms
{469: <Alarm id:469@22:07:41 at 0x7f5198797dc0>,
471: <Alarm id:471@22:08:40 at 0x7f51987f1b50>}
>>> for alarm in alarms:
... alarm.remove()
...
>>> a.alarms
{}
"""
_class_group = "Alarms"
def __init__(self):
"""Initialize the instance."""
self.alarms = {}
self._last_zone_used = None
self._last_alarm_list_version = None
self.last_uid = None
self.last_id = 0
@property
def last_alarm_list_version(self):
"""Return last seen alarm list version."""
return self._last_alarm_list_version
@last_alarm_list_version.setter
def last_alarm_list_version(self, alarm_list_version):
"""Store alarm list version and store UID/ID values."""
self.last_uid, last_id = alarm_list_version.split(":")
self.last_id = int(last_id)
self._last_alarm_list_version = alarm_list_version
def __iter__(self):
"""Return an interator for all alarms."""
for alarm in list(self.alarms.values()):
yield alarm
def __len__(self):
"""Return the number of alarms."""
return len(self.alarms)
def __getitem__(self, alarm_id):
"""Return the alarm by ID."""
return self.alarms[alarm_id]
[docs] def get(self, alarm_id):
"""Return the alarm by ID or None."""
return self.alarms.get(alarm_id)
[docs] def update(self, zone=None):
"""Update all alarms and current alarm list version.
Raises:
SoCoException: If the 'CurrentAlarmListVersion' value is unexpected.
May occur if the provided zone is from a different household.
"""
if zone is None:
zone = self._last_zone_used or discovery.any_soco()
self._last_zone_used = zone
response = zone.alarmClock.ListAlarms()
current_alarm_list_version = response["CurrentAlarmListVersion"]
if self.last_alarm_list_version:
alarm_list_uid, alarm_list_id = current_alarm_list_version.split(":")
if self.last_uid != alarm_list_uid:
matching_zone = next(
(z for z in zone.all_zones if z.uid == alarm_list_uid), None
)
if not matching_zone:
raise SoCoException(
"Alarm list UID {} does not match {}".format(
current_alarm_list_version, self.last_alarm_list_version
)
)
if int(alarm_list_id) <= self.last_id:
return
self.last_alarm_list_version = current_alarm_list_version
new_alarms = parse_alarm_payload(response, zone)
# Update existing and create new Alarm instances
for alarm_id, kwargs in new_alarms.items():
existing_alarm = self.alarms.get(alarm_id)
if existing_alarm:
existing_alarm.update(**kwargs)
else:
new_alarm = Alarm(**kwargs)
new_alarm._alarm_id = alarm_id # pylint: disable=protected-access
self.alarms[alarm_id] = new_alarm
# Prune alarms removed externally
for alarm_id in list(self.alarms):
if not new_alarms.get(alarm_id):
self.alarms.pop(alarm_id)
[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.
"""
# pylint: disable=too-many-instance-attributes
# 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`.
"""
self.zone = zone
if start_time is None:
start_time = datetime.now().time().replace(microsecond=0)
self.start_time = start_time
self.duration = duration
self.recurrence = recurrence
self.enabled = enabled
self.program_uri = program_uri
self.program_metadata = program_metadata
self.play_mode = play_mode
self.volume = volume
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))
)
[docs] def update(self, **kwargs):
"""Update an existing Alarm instance using the same arguments as __init__."""
for attr, value in kwargs.items():
if not hasattr(self, attr):
raise SoCoException("Alarm does not have atttribute {}".format(attr))
setattr(self, attr, value)
@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.
"""
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"]
alarms = Alarms()
if alarms.last_id == int(self.alarm_id) - 1:
alarms.last_alarm_list_version = "{}:{}".format(
alarms.last_uid, self.alarm_id
)
alarms.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.
Returns:
bool: If the removal was sucessful.
"""
result = self.zone.alarmClock.DestroyAlarm([("ID", self.alarm_id)])
alarms = Alarms()
alarms.alarms.pop(self.alarm_id, None)
self._alarm_id = None
return result
@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
"""
alarms = Alarms()
alarms.update(zone)
return set(alarms.alarms.values())
[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 = Alarms()
alarms.update(zone)
alarm = alarms.get(alarm_id)
if not alarm:
return False
return alarm.remove()
[docs]def parse_alarm_payload(payload, zone):
"""Parse the XML payload response and return a dict of `Alarm` kwargs."""
alarm_list = payload["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>
alarms = tree.findall("Alarm")
alarm_args = {}
for alarm in alarms:
values = alarm.attrib
alarm_id = values["ID"]
alarm_zone = next(
(z for z in zone.all_zones if z.uid == values["RoomUUID"]), None
)
if alarm_zone is None:
# Some alarms are not associated with a zone, ignore these
continue
args = {
"zone": alarm_zone,
# StartTime not StartLocalTime which is used by CreateAlarm
"start_time": datetime.strptime(values["StartTime"], "%H:%M:%S").time(),
"duration": (
None
if values["Duration"] == ""
else datetime.strptime(values["Duration"], "%H:%M:%S").time()
),
"recurrence": values["Recurrence"],
"enabled": values["Enabled"] == "1",
"program_uri": (
None
if values["ProgramURI"] == "x-rincon-buzzer:0"
else values["ProgramURI"]
),
"program_metadata": values["ProgramMetaData"],
"play_mode": values["PlayMode"],
"volume": values["Volume"],
"include_linked_zones": values["IncludeLinkedZones"] == "1",
}
alarm_args[alarm_id] = args
return alarm_args