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