aioamazondevices 9.0.3__tar.gz → 10.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/PKG-INFO +1 -1
  2. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/pyproject.toml +1 -1
  3. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/src/aioamazondevices/__init__.py +1 -1
  4. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/src/aioamazondevices/api.py +48 -357
  5. aioamazondevices-10.0.0/src/aioamazondevices/implementation/__init__.py +1 -0
  6. aioamazondevices-10.0.0/src/aioamazondevices/implementation/notification.py +223 -0
  7. aioamazondevices-10.0.0/src/aioamazondevices/implementation/sequence.py +159 -0
  8. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/src/aioamazondevices/structures.py +1 -1
  9. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/LICENSE +0 -0
  10. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/README.md +0 -0
  11. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/src/aioamazondevices/const/__init__.py +0 -0
  12. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/src/aioamazondevices/const/devices.py +0 -0
  13. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/src/aioamazondevices/const/http.py +0 -0
  14. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/src/aioamazondevices/const/metadata.py +0 -0
  15. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/src/aioamazondevices/const/queries.py +0 -0
  16. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/src/aioamazondevices/const/schedules.py +0 -0
  17. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/src/aioamazondevices/const/sounds.py +0 -0
  18. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/src/aioamazondevices/exceptions.py +0 -0
  19. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/src/aioamazondevices/http_wrapper.py +0 -0
  20. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/src/aioamazondevices/login.py +0 -0
  21. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/src/aioamazondevices/py.typed +0 -0
  22. {aioamazondevices-9.0.3 → aioamazondevices-10.0.0}/src/aioamazondevices/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aioamazondevices
3
- Version: 9.0.3
3
+ Version: 10.0.0
4
4
  Summary: Python library to control Amazon devices
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aioamazondevices"
3
- version = "9.0.3"
3
+ version = "10.0.0"
4
4
  requires-python = ">=3.12"
5
5
  description = "Python library to control Amazon devices"
6
6
  authors = [
@@ -1,6 +1,6 @@
1
1
  """aioamazondevices library."""
2
2
 
3
- __version__ = "9.0.3"
3
+ __version__ = "10.0.0"
4
4
 
5
5
 
6
6
  from .api import AmazonDevice, AmazonEchoApi
@@ -5,10 +5,7 @@ from datetime import UTC, datetime, timedelta
5
5
  from http import HTTPMethod
6
6
  from typing import Any
7
7
 
8
- import orjson
9
8
  from aiohttp import ClientSession
10
- from dateutil.parser import parse
11
- from dateutil.rrule import rrulestr
12
9
 
13
10
  from . import __version__
14
11
  from .const.devices import (
@@ -22,30 +19,25 @@ from .const.http import (
22
19
  URI_DEVICES,
23
20
  URI_DND,
24
21
  URI_NEXUS_GRAPHQL,
25
- URI_NOTIFICATIONS,
26
22
  )
27
- from .const.metadata import ALEXA_INFO_SKILLS, SENSORS
23
+ from .const.metadata import SENSORS
28
24
  from .const.queries import QUERY_DEVICE_DATA, QUERY_SENSOR_STATE
29
25
  from .const.schedules import (
30
- COUNTRY_GROUPS,
31
26
  NOTIFICATION_ALARM,
32
- NOTIFICATION_MUSIC_ALARM,
33
27
  NOTIFICATION_REMINDER,
34
28
  NOTIFICATION_TIMER,
35
- NOTIFICATIONS_SUPPORTED,
36
- RECURRING_PATTERNS,
37
- WEEKEND_EXCEPTIONS,
38
29
  )
39
30
  from .exceptions import (
40
31
  CannotRetrieveData,
41
32
  )
42
33
  from .http_wrapper import AmazonHttpWrapper, AmazonSessionStateData
34
+ from .implementation.notification import AmazonNotificationHandler
35
+ from .implementation.sequence import AmazonSequenceHandler
43
36
  from .login import AmazonLogin
44
37
  from .structures import (
45
38
  AmazonDevice,
46
39
  AmazonDeviceSensor,
47
40
  AmazonMusicSource,
48
- AmazonSchedule,
49
41
  AmazonSequenceType,
50
42
  )
51
43
  from .utils import _LOGGER
@@ -85,7 +77,15 @@ class AmazonEchoApi:
85
77
  session_state_data=self._session_state_data,
86
78
  )
87
79
 
88
- self._list_for_clusters: dict[str, str] = {}
80
+ self._notification_handler = AmazonNotificationHandler(
81
+ http_wrapper=self._http_wrapper,
82
+ session_state_data=self._session_state_data,
83
+ )
84
+
85
+ self._sequence_handler = AmazonSequenceHandler(
86
+ http_wrapper=self._http_wrapper,
87
+ session_state_data=self._session_state_data,
88
+ )
89
89
 
90
90
  self._final_devices: dict[str, AmazonDevice] = {}
91
91
  self._endpoints: dict[str, str] = {} # endpoint ID to serial number map
@@ -258,190 +258,6 @@ class AmazonEchoApi:
258
258
 
259
259
  return devices_endpoints
260
260
 
261
- async def _get_notifications(self) -> dict[str, dict[str, AmazonSchedule]] | None:
262
- final_notifications: dict[str, dict[str, AmazonSchedule]] = {}
263
-
264
- try:
265
- _, raw_resp = await self._http_wrapper.session_request(
266
- HTTPMethod.GET,
267
- url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_NOTIFICATIONS}",
268
- )
269
- except CannotRetrieveData:
270
- _LOGGER.warning(
271
- "Failed to obtain notification data. Timers and alarms have not been updated" # noqa: E501
272
- )
273
- return None
274
-
275
- notifications = await self._http_wrapper.response_to_json(
276
- raw_resp, "notifications"
277
- )
278
-
279
- for schedule in notifications["notifications"]:
280
- schedule_type: str = schedule["type"]
281
- schedule_device_type = schedule["deviceType"]
282
- schedule_device_serial = schedule["deviceSerialNumber"]
283
-
284
- if schedule_device_type in DEVICE_TO_IGNORE:
285
- continue
286
-
287
- if schedule_type not in NOTIFICATIONS_SUPPORTED:
288
- _LOGGER.debug(
289
- "Unsupported schedule type %s for device %s",
290
- schedule_type,
291
- schedule_device_serial,
292
- )
293
- continue
294
-
295
- if schedule_type == NOTIFICATION_MUSIC_ALARM:
296
- # Structure is the same as standard Alarm
297
- schedule_type = NOTIFICATION_ALARM
298
- schedule["type"] = NOTIFICATION_ALARM
299
- label_desc = schedule_type.lower() + "Label"
300
- if (schedule_status := schedule["status"]) == "ON" and (
301
- next_occurrence := await self._parse_next_occurence(schedule)
302
- ):
303
- schedule_notification_list = final_notifications.get(
304
- schedule_device_serial, {}
305
- )
306
- schedule_notification_by_type = schedule_notification_list.get(
307
- schedule_type
308
- )
309
- # Replace if no existing notification
310
- # or if existing.next_occurrence is None
311
- # or if new next_occurrence is earlier
312
- if (
313
- not schedule_notification_by_type
314
- or schedule_notification_by_type.next_occurrence is None
315
- or next_occurrence < schedule_notification_by_type.next_occurrence
316
- ):
317
- final_notifications.update(
318
- {
319
- schedule_device_serial: {
320
- **schedule_notification_list
321
- | {
322
- schedule_type: AmazonSchedule(
323
- type=schedule_type,
324
- status=schedule_status,
325
- label=schedule[label_desc],
326
- next_occurrence=next_occurrence,
327
- ),
328
- }
329
- }
330
- }
331
- )
332
-
333
- return final_notifications
334
-
335
- async def _parse_next_occurence(
336
- self,
337
- schedule: dict[str, Any],
338
- ) -> datetime | None:
339
- """Parse RFC5545 rule set for next iteration."""
340
- # Local timezone
341
- tzinfo = datetime.now().astimezone().tzinfo
342
- # Current time
343
- actual_time = datetime.now(tz=tzinfo)
344
- # Reference start date
345
- today_midnight = actual_time.replace(hour=0, minute=0, second=0, microsecond=0)
346
- # Reference time (1 minute ago to avoid edge cases)
347
- now_reference = actual_time - timedelta(minutes=1)
348
-
349
- # Schedule data
350
- original_date = schedule.get("originalDate")
351
- original_time = schedule.get("originalTime")
352
-
353
- recurring_rules: list[str] = []
354
- if schedule.get("rRuleData"):
355
- recurring_rules = schedule["rRuleData"]["recurrenceRules"]
356
- if schedule.get("recurringPattern"):
357
- recurring_rules.append(schedule["recurringPattern"])
358
-
359
- # Recurring events
360
- if recurring_rules:
361
- next_candidates: list[datetime] = []
362
- for recurring_rule in recurring_rules:
363
- # Already in RFC5545 format
364
- if "FREQ=" in recurring_rule:
365
- rule = await self._add_hours_minutes(recurring_rule, original_time)
366
-
367
- # Add date to candidates list
368
- next_candidates.append(
369
- rrulestr(rule, dtstart=today_midnight).after(
370
- now_reference, True
371
- ),
372
- )
373
- continue
374
-
375
- if recurring_rule not in RECURRING_PATTERNS:
376
- _LOGGER.warning(
377
- "Unknown recurring rule <%s> for schedule type <%s>",
378
- recurring_rule,
379
- schedule["type"],
380
- )
381
- return None
382
-
383
- # Adjust recurring rules for country specific weekend exceptions
384
- recurring_pattern = RECURRING_PATTERNS.copy()
385
- for group, countries in COUNTRY_GROUPS.items():
386
- if self._session_state_data.country_code in countries:
387
- recurring_pattern |= WEEKEND_EXCEPTIONS[group]
388
- break
389
-
390
- rule = await self._add_hours_minutes(
391
- recurring_pattern[recurring_rule], original_time
392
- )
393
-
394
- # Add date to candidates list
395
- next_candidates.append(
396
- rrulestr(rule, dtstart=today_midnight).after(now_reference, True),
397
- )
398
-
399
- return min(next_candidates) if next_candidates else None
400
-
401
- # Single events
402
- if schedule["type"] == NOTIFICATION_ALARM:
403
- timestamp = parse(f"{original_date} {original_time}").replace(tzinfo=tzinfo)
404
-
405
- elif schedule["type"] == NOTIFICATION_TIMER:
406
- # API returns triggerTime in milliseconds since epoch
407
- timestamp = datetime.fromtimestamp(
408
- schedule["triggerTime"] / 1000, tz=tzinfo
409
- )
410
-
411
- elif schedule["type"] == NOTIFICATION_REMINDER:
412
- # API returns alarmTime in milliseconds since epoch
413
- timestamp = datetime.fromtimestamp(schedule["alarmTime"] / 1000, tz=tzinfo)
414
-
415
- else:
416
- _LOGGER.warning(("Unknown schedule type: %s"), schedule["type"])
417
- return None
418
-
419
- if timestamp > now_reference:
420
- return timestamp
421
-
422
- return None
423
-
424
- async def _add_hours_minutes(
425
- self,
426
- recurring_rule: str,
427
- original_time: str | None,
428
- ) -> str:
429
- """Add hours and minutes to a RFC5545 string."""
430
- rule = recurring_rule.removesuffix(";")
431
-
432
- if not original_time:
433
- return rule
434
-
435
- # Add missing BYHOUR, BYMINUTE if needed (Alarms only)
436
- if "BYHOUR=" not in recurring_rule:
437
- hour = int(original_time.split(":")[0])
438
- rule += f";BYHOUR={hour}"
439
- if "BYMINUTE=" not in recurring_rule:
440
- minute = int(original_time.split(":")[1])
441
- rule += f";BYMINUTE={minute}"
442
-
443
- return rule
444
-
445
261
  async def get_devices_data(
446
262
  self,
447
263
  ) -> dict[str, AmazonDevice]:
@@ -478,7 +294,7 @@ class AmazonEchoApi:
478
294
  async def _get_sensor_data(self) -> None:
479
295
  devices_sensors = await self._get_sensors_states()
480
296
  dnd_sensors = await self._get_dnd_status()
481
- notifications = await self._get_notifications()
297
+ notifications = await self._notification_handler.get_notifications()
482
298
  for device in self._final_devices.values():
483
299
  # Update sensors
484
300
  sensors = devices_sensors.get(device.serial_number, {})
@@ -542,6 +358,7 @@ class AmazonEchoApi:
542
358
  json_data = await self._http_wrapper.response_to_json(raw_resp, "devices")
543
359
 
544
360
  final_devices_list: dict[str, AmazonDevice] = {}
361
+ serial_to_device_type: dict[str, str] = {}
545
362
  for device in json_data["devices"]:
546
363
  # Remove stale, orphaned and virtual devices
547
364
  if not device or (device.get("deviceType") in DEVICE_TO_IGNORE):
@@ -566,7 +383,9 @@ class AmazonEchoApi:
566
383
  device_owner_customer_id=device["deviceOwnerCustomerId"],
567
384
  household_device=device["deviceOwnerCustomerId"]
568
385
  == self._session_state_data.account_customer_id,
569
- device_cluster_members=(device["clusterMembers"] or [serial_number]),
386
+ device_cluster_members=dict.fromkeys(
387
+ device["clusterMembers"] or [serial_number]
388
+ ),
570
389
  online=device["online"],
571
390
  serial_number=serial_number,
572
391
  software_version=device["softwareVersion"],
@@ -576,12 +395,14 @@ class AmazonEchoApi:
576
395
  notifications={},
577
396
  )
578
397
 
579
- self._list_for_clusters.update(
580
- {
581
- device.serial_number: device.device_type
582
- for device in final_devices_list.values()
583
- }
584
- )
398
+ serial_to_device_type[serial_number] = device["deviceType"]
399
+
400
+ # backfill device types for cluster members
401
+ for device in final_devices_list.values():
402
+ for member_serial in device.device_cluster_members:
403
+ device.device_cluster_members[member_serial] = (
404
+ serial_to_device_type.get(member_serial)
405
+ )
585
406
 
586
407
  self._final_devices = final_devices_list
587
408
 
@@ -599,204 +420,74 @@ class AmazonEchoApi:
599
420
 
600
421
  return model_details
601
422
 
602
- async def _send_message(
603
- self,
604
- device: AmazonDevice,
605
- message_type: str,
606
- message_body: str,
607
- message_source: AmazonMusicSource | None = None,
608
- ) -> None:
609
- """Send message to specific device."""
610
- if not self._session_state_data.login_stored_data:
611
- _LOGGER.warning("No login data available, cannot send message")
612
- return
613
-
614
- base_payload = {
615
- "deviceType": device.device_type,
616
- "deviceSerialNumber": device.serial_number,
617
- "locale": self._session_state_data.language,
618
- "customerId": self._session_state_data.account_customer_id,
619
- }
620
-
621
- payload: dict[str, Any]
622
- if message_type == AmazonSequenceType.Speak:
623
- payload = {
624
- **base_payload,
625
- "textToSpeak": message_body,
626
- "target": {
627
- "customerId": self._session_state_data.account_customer_id,
628
- "devices": [
629
- {
630
- "deviceSerialNumber": device.serial_number,
631
- "deviceTypeId": device.device_type,
632
- },
633
- ],
634
- },
635
- "skillId": "amzn1.ask.1p.saysomething",
636
- }
637
- elif message_type == AmazonSequenceType.Announcement:
638
- playback_devices: list[dict[str, str]] = [
639
- {
640
- "deviceSerialNumber": serial,
641
- "deviceTypeId": self._list_for_clusters[serial],
642
- }
643
- for serial in device.device_cluster_members
644
- if serial in self._list_for_clusters
645
- ]
646
-
647
- payload = {
648
- **base_payload,
649
- "expireAfter": "PT5S",
650
- "content": [
651
- {
652
- "locale": self._session_state_data.language,
653
- "display": {
654
- "title": "Home Assistant",
655
- "body": message_body,
656
- },
657
- "speak": {
658
- "type": "text",
659
- "value": message_body,
660
- },
661
- }
662
- ],
663
- "target": {
664
- "customerId": self._session_state_data.account_customer_id,
665
- "devices": playback_devices,
666
- },
667
- "skillId": "amzn1.ask.1p.routines.messaging",
668
- }
669
- elif message_type == AmazonSequenceType.Sound:
670
- payload = {
671
- **base_payload,
672
- "soundStringId": message_body,
673
- "skillId": "amzn1.ask.1p.sound",
674
- }
675
- elif message_type == AmazonSequenceType.Music:
676
- payload = {
677
- **base_payload,
678
- "searchPhrase": message_body,
679
- "sanitizedSearchPhrase": message_body,
680
- "musicProviderId": message_source,
681
- }
682
- elif message_type == AmazonSequenceType.TextCommand:
683
- payload = {
684
- **base_payload,
685
- "skillId": "amzn1.ask.1p.tellalexa",
686
- "text": message_body,
687
- }
688
- elif message_type == AmazonSequenceType.LaunchSkill:
689
- payload = {
690
- **base_payload,
691
- "targetDevice": {
692
- "deviceType": device.device_type,
693
- "deviceSerialNumber": device.serial_number,
694
- },
695
- "connectionRequest": {
696
- "uri": "connection://AMAZON.Launch/" + message_body,
697
- },
698
- }
699
- elif message_type in ALEXA_INFO_SKILLS:
700
- payload = {
701
- **base_payload,
702
- }
703
- else:
704
- raise ValueError(f"Message type <{message_type}> is not recognised")
705
-
706
- sequence = {
707
- "@type": "com.amazon.alexa.behaviors.model.Sequence",
708
- "startNode": {
709
- "@type": "com.amazon.alexa.behaviors.model.SerialNode",
710
- "nodesToExecute": [
711
- {
712
- "@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode", # noqa: E501
713
- "type": message_type,
714
- "operationPayload": payload,
715
- },
716
- ],
717
- },
718
- }
719
-
720
- node_data = {
721
- "behaviorId": "PREVIEW",
722
- "sequenceJson": orjson.dumps(sequence).decode("utf-8"),
723
- "status": "ENABLED",
724
- }
725
-
726
- _LOGGER.debug("Preview data payload: %s", node_data)
727
- await self._http_wrapper.session_request(
728
- method=HTTPMethod.POST,
729
- url=f"https://alexa.amazon.{self._session_state_data.domain}/api/behaviors/preview",
730
- input_data=node_data,
731
- json_data=True,
732
- )
733
-
734
- return
735
-
736
423
  async def call_alexa_speak(
737
424
  self,
738
425
  device: AmazonDevice,
739
- message_body: str,
426
+ text_to_speak: str,
740
427
  ) -> None:
741
428
  """Call Alexa.Speak to send a message."""
742
- return await self._send_message(device, AmazonSequenceType.Speak, message_body)
429
+ await self._sequence_handler.send_message(
430
+ device, AmazonSequenceType.Speak, text_to_speak
431
+ )
743
432
 
744
433
  async def call_alexa_announcement(
745
434
  self,
746
435
  device: AmazonDevice,
747
- message_body: str,
436
+ text_to_announce: str,
748
437
  ) -> None:
749
438
  """Call AlexaAnnouncement to send a message."""
750
- return await self._send_message(
751
- device, AmazonSequenceType.Announcement, message_body
439
+ await self._sequence_handler.send_message(
440
+ device, AmazonSequenceType.Announcement, text_to_announce
752
441
  )
753
442
 
754
443
  async def call_alexa_sound(
755
444
  self,
756
445
  device: AmazonDevice,
757
- message_body: str,
446
+ sound_name: str,
758
447
  ) -> None:
759
448
  """Call Alexa.Sound to play sound."""
760
- return await self._send_message(device, AmazonSequenceType.Sound, message_body)
449
+ await self._sequence_handler.send_message(
450
+ device, AmazonSequenceType.Sound, sound_name
451
+ )
761
452
 
762
453
  async def call_alexa_music(
763
454
  self,
764
455
  device: AmazonDevice,
765
- message_body: str,
766
- message_source: AmazonMusicSource,
456
+ search_phrase: str,
457
+ music_source: AmazonMusicSource,
767
458
  ) -> None:
768
459
  """Call Alexa.Music.PlaySearchPhrase to play music."""
769
- return await self._send_message(
770
- device, AmazonSequenceType.Music, message_body, message_source
460
+ await self._sequence_handler.send_message(
461
+ device, AmazonSequenceType.Music, search_phrase, music_source
771
462
  )
772
463
 
773
464
  async def call_alexa_text_command(
774
465
  self,
775
466
  device: AmazonDevice,
776
- message_body: str,
467
+ text_command: str,
777
468
  ) -> None:
778
469
  """Call Alexa.TextCommand to issue command."""
779
- return await self._send_message(
780
- device, AmazonSequenceType.TextCommand, message_body
470
+ await self._sequence_handler.send_message(
471
+ device, AmazonSequenceType.TextCommand, text_command
781
472
  )
782
473
 
783
474
  async def call_alexa_skill(
784
475
  self,
785
476
  device: AmazonDevice,
786
- message_body: str,
477
+ skill_name: str,
787
478
  ) -> None:
788
479
  """Call Alexa.LaunchSkill to launch a skill."""
789
- return await self._send_message(
790
- device, AmazonSequenceType.LaunchSkill, message_body
480
+ await self._sequence_handler.send_message(
481
+ device, AmazonSequenceType.LaunchSkill, skill_name
791
482
  )
792
483
 
793
484
  async def call_alexa_info_skill(
794
485
  self,
795
486
  device: AmazonDevice,
796
- message_type: str,
487
+ info_skill_name: str,
797
488
  ) -> None:
798
489
  """Call Info skill. See ALEXA_INFO_SKILLS . const."""
799
- return await self._send_message(device, message_type, "")
490
+ await self._sequence_handler.send_message(device, info_skill_name, "")
800
491
 
801
492
  async def set_do_not_disturb(self, device: AmazonDevice, state: bool) -> None:
802
493
  """Set do_not_disturb flag."""
@@ -0,0 +1 @@
1
+ """aioamazondevices implementation package."""
@@ -0,0 +1,223 @@
1
+ """Module to handle Alexa notifications."""
2
+
3
+ from datetime import datetime, timedelta
4
+ from http import HTTPMethod
5
+ from typing import Any
6
+
7
+ from dateutil.parser import parse
8
+ from dateutil.rrule import rrulestr
9
+
10
+ from aioamazondevices.const.devices import DEVICE_TO_IGNORE
11
+ from aioamazondevices.const.http import URI_NOTIFICATIONS
12
+ from aioamazondevices.const.schedules import (
13
+ COUNTRY_GROUPS,
14
+ NOTIFICATION_ALARM,
15
+ NOTIFICATION_MUSIC_ALARM,
16
+ NOTIFICATION_REMINDER,
17
+ NOTIFICATION_TIMER,
18
+ NOTIFICATIONS_SUPPORTED,
19
+ RECURRING_PATTERNS,
20
+ WEEKEND_EXCEPTIONS,
21
+ )
22
+ from aioamazondevices.exceptions import CannotRetrieveData
23
+ from aioamazondevices.http_wrapper import AmazonHttpWrapper, AmazonSessionStateData
24
+ from aioamazondevices.structures import AmazonSchedule
25
+ from aioamazondevices.utils import _LOGGER
26
+
27
+
28
+ class AmazonNotificationHandler:
29
+ """Class to handle Alexa notifications."""
30
+
31
+ def __init__(
32
+ self,
33
+ session_state_data: AmazonSessionStateData,
34
+ http_wrapper: AmazonHttpWrapper,
35
+ ) -> None:
36
+ """Initialize AmazonNotificationHandler class."""
37
+ self._session_state_data = session_state_data
38
+ self._http_wrapper = http_wrapper
39
+
40
+ async def get_notifications(self) -> dict[str, dict[str, AmazonSchedule]] | None:
41
+ """Get all notifications (alarms, timers, reminders)."""
42
+ final_notifications: dict[str, dict[str, AmazonSchedule]] = {}
43
+
44
+ try:
45
+ _, raw_resp = await self._http_wrapper.session_request(
46
+ HTTPMethod.GET,
47
+ url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_NOTIFICATIONS}",
48
+ )
49
+ except CannotRetrieveData:
50
+ _LOGGER.warning(
51
+ "Failed to obtain notification data. Timers and alarms have not been updated" # noqa: E501
52
+ )
53
+ return None
54
+
55
+ notifications = await self._http_wrapper.response_to_json(
56
+ raw_resp, "notifications"
57
+ )
58
+
59
+ for schedule in notifications["notifications"]:
60
+ schedule_type: str = schedule["type"]
61
+ schedule_device_type = schedule["deviceType"]
62
+ schedule_device_serial = schedule["deviceSerialNumber"]
63
+
64
+ if schedule_device_type in DEVICE_TO_IGNORE:
65
+ continue
66
+
67
+ if schedule_type not in NOTIFICATIONS_SUPPORTED:
68
+ _LOGGER.debug(
69
+ "Unsupported schedule type %s for device %s",
70
+ schedule_type,
71
+ schedule_device_serial,
72
+ )
73
+ continue
74
+
75
+ if schedule_type == NOTIFICATION_MUSIC_ALARM:
76
+ # Structure is the same as standard Alarm
77
+ schedule_type = NOTIFICATION_ALARM
78
+ schedule["type"] = NOTIFICATION_ALARM
79
+ label_desc = schedule_type.lower() + "Label"
80
+ if (schedule_status := schedule["status"]) == "ON" and (
81
+ next_occurrence := await self._parse_next_occurrence(schedule)
82
+ ):
83
+ schedule_notification_list = final_notifications.get(
84
+ schedule_device_serial, {}
85
+ )
86
+ schedule_notification_by_type = schedule_notification_list.get(
87
+ schedule_type
88
+ )
89
+ # Replace if no existing notification
90
+ # or if existing.next_occurrence is None
91
+ # or if new next_occurrence is earlier
92
+ if (
93
+ not schedule_notification_by_type
94
+ or schedule_notification_by_type.next_occurrence is None
95
+ or next_occurrence < schedule_notification_by_type.next_occurrence
96
+ ):
97
+ final_notifications.update(
98
+ {
99
+ schedule_device_serial: {
100
+ **schedule_notification_list
101
+ | {
102
+ schedule_type: AmazonSchedule(
103
+ type=schedule_type,
104
+ status=schedule_status,
105
+ label=schedule[label_desc],
106
+ next_occurrence=next_occurrence,
107
+ ),
108
+ }
109
+ }
110
+ }
111
+ )
112
+
113
+ return final_notifications
114
+
115
+ async def _parse_next_occurrence(
116
+ self,
117
+ schedule: dict[str, Any],
118
+ ) -> datetime | None:
119
+ """Parse RFC5545 rule set for next iteration."""
120
+ # Local timezone
121
+ tzinfo = datetime.now().astimezone().tzinfo
122
+ # Current time
123
+ actual_time = datetime.now(tz=tzinfo)
124
+ # Reference start date
125
+ today_midnight = actual_time.replace(hour=0, minute=0, second=0, microsecond=0)
126
+ # Reference time (1 minute ago to avoid edge cases)
127
+ now_reference = actual_time - timedelta(minutes=1)
128
+
129
+ # Schedule data
130
+ original_date = schedule.get("originalDate")
131
+ original_time = schedule.get("originalTime")
132
+
133
+ recurring_rules: list[str] = []
134
+ if schedule.get("rRuleData"):
135
+ recurring_rules = schedule["rRuleData"]["recurrenceRules"]
136
+ if schedule.get("recurringPattern"):
137
+ recurring_rules.append(schedule["recurringPattern"])
138
+
139
+ # Recurring events
140
+ if recurring_rules:
141
+ next_candidates: list[datetime] = []
142
+ for recurring_rule in recurring_rules:
143
+ # Already in RFC5545 format
144
+ if "FREQ=" in recurring_rule:
145
+ rule = await self._add_hours_minutes(recurring_rule, original_time)
146
+
147
+ # Add date to candidates list
148
+ next_candidates.append(
149
+ rrulestr(rule, dtstart=today_midnight).after(
150
+ now_reference, True
151
+ ),
152
+ )
153
+ continue
154
+
155
+ if recurring_rule not in RECURRING_PATTERNS:
156
+ _LOGGER.warning(
157
+ "Unknown recurring rule <%s> for schedule type <%s>",
158
+ recurring_rule,
159
+ schedule["type"],
160
+ )
161
+ return None
162
+
163
+ # Adjust recurring rules for country specific weekend exceptions
164
+ recurring_pattern = RECURRING_PATTERNS.copy()
165
+ for group, countries in COUNTRY_GROUPS.items():
166
+ if self._session_state_data.country_code in countries:
167
+ recurring_pattern |= WEEKEND_EXCEPTIONS[group]
168
+ break
169
+
170
+ rule = await self._add_hours_minutes(
171
+ recurring_pattern[recurring_rule], original_time
172
+ )
173
+
174
+ # Add date to candidates list
175
+ next_candidates.append(
176
+ rrulestr(rule, dtstart=today_midnight).after(now_reference, True),
177
+ )
178
+
179
+ return min(next_candidates) if next_candidates else None
180
+
181
+ # Single events
182
+ if schedule["type"] == NOTIFICATION_ALARM:
183
+ timestamp = parse(f"{original_date} {original_time}").replace(tzinfo=tzinfo)
184
+
185
+ elif schedule["type"] == NOTIFICATION_TIMER:
186
+ # API returns triggerTime in milliseconds since epoch
187
+ timestamp = datetime.fromtimestamp(
188
+ schedule["triggerTime"] / 1000, tz=tzinfo
189
+ )
190
+
191
+ elif schedule["type"] == NOTIFICATION_REMINDER:
192
+ # API returns alarmTime in milliseconds since epoch
193
+ timestamp = datetime.fromtimestamp(schedule["alarmTime"] / 1000, tz=tzinfo)
194
+
195
+ else:
196
+ _LOGGER.warning(("Unknown schedule type: %s"), schedule["type"])
197
+ return None
198
+
199
+ if timestamp > now_reference:
200
+ return timestamp
201
+
202
+ return None
203
+
204
+ async def _add_hours_minutes(
205
+ self,
206
+ recurring_rule: str,
207
+ original_time: str | None,
208
+ ) -> str:
209
+ """Add hours and minutes to a RFC5545 string."""
210
+ rule = recurring_rule.removesuffix(";")
211
+
212
+ if not original_time:
213
+ return rule
214
+
215
+ # Add missing BYHOUR, BYMINUTE if needed (Alarms only)
216
+ if "BYHOUR=" not in recurring_rule:
217
+ hour = int(original_time.split(":")[0])
218
+ rule += f";BYHOUR={hour}"
219
+ if "BYMINUTE=" not in recurring_rule:
220
+ minute = int(original_time.split(":")[1])
221
+ rule += f";BYMINUTE={minute}"
222
+
223
+ return rule
@@ -0,0 +1,159 @@
1
+ """Module to handle Alexa sequence operations."""
2
+
3
+ from http import HTTPMethod
4
+ from typing import Any
5
+
6
+ import orjson
7
+
8
+ from aioamazondevices.const.metadata import ALEXA_INFO_SKILLS
9
+ from aioamazondevices.http_wrapper import AmazonHttpWrapper, AmazonSessionStateData
10
+ from aioamazondevices.structures import (
11
+ AmazonDevice,
12
+ AmazonMusicSource,
13
+ AmazonSequenceType,
14
+ )
15
+ from aioamazondevices.utils import _LOGGER
16
+
17
+
18
+ class AmazonSequenceHandler:
19
+ """Class to handle Alexa sequence operations."""
20
+
21
+ def __init__(
22
+ self,
23
+ http_wrapper: AmazonHttpWrapper,
24
+ session_state_data: AmazonSessionStateData,
25
+ ) -> None:
26
+ """Initialize AmazonSequenceHandler."""
27
+ self._http_wrapper = http_wrapper
28
+ self._session_state_data = session_state_data
29
+
30
+ async def send_message(
31
+ self,
32
+ device: AmazonDevice,
33
+ message_type: str,
34
+ message_body: str,
35
+ message_source: AmazonMusicSource | None = None,
36
+ ) -> None:
37
+ """Send message to specific device."""
38
+ if not self._session_state_data.login_stored_data:
39
+ _LOGGER.warning("No login data available, cannot send message")
40
+ return
41
+
42
+ base_payload = {
43
+ "deviceType": device.device_type,
44
+ "deviceSerialNumber": device.serial_number,
45
+ "locale": self._session_state_data.language,
46
+ "customerId": self._session_state_data.account_customer_id,
47
+ }
48
+
49
+ payload: dict[str, Any]
50
+ if message_type == AmazonSequenceType.Speak:
51
+ payload = {
52
+ **base_payload,
53
+ "textToSpeak": message_body,
54
+ "target": {
55
+ "customerId": self._session_state_data.account_customer_id,
56
+ "devices": [
57
+ {
58
+ "deviceSerialNumber": device.serial_number,
59
+ "deviceTypeId": device.device_type,
60
+ },
61
+ ],
62
+ },
63
+ "skillId": "amzn1.ask.1p.saysomething",
64
+ }
65
+ elif message_type == AmazonSequenceType.Announcement:
66
+ playback_devices: list[dict[str, str | None]] = [
67
+ {
68
+ "deviceSerialNumber": serial,
69
+ "deviceTypeId": device.device_cluster_members[serial],
70
+ }
71
+ for serial in device.device_cluster_members
72
+ ]
73
+
74
+ payload = {
75
+ **base_payload,
76
+ "expireAfter": "PT5S",
77
+ "content": [
78
+ {
79
+ "locale": self._session_state_data.language,
80
+ "display": {
81
+ "title": "Home Assistant",
82
+ "body": message_body,
83
+ },
84
+ "speak": {
85
+ "type": "text",
86
+ "value": message_body,
87
+ },
88
+ }
89
+ ],
90
+ "target": {
91
+ "customerId": self._session_state_data.account_customer_id,
92
+ "devices": playback_devices,
93
+ },
94
+ "skillId": "amzn1.ask.1p.routines.messaging",
95
+ }
96
+ elif message_type == AmazonSequenceType.Sound:
97
+ payload = {
98
+ **base_payload,
99
+ "soundStringId": message_body,
100
+ "skillId": "amzn1.ask.1p.sound",
101
+ }
102
+ elif message_type == AmazonSequenceType.Music:
103
+ payload = {
104
+ **base_payload,
105
+ "searchPhrase": message_body,
106
+ "sanitizedSearchPhrase": message_body,
107
+ "musicProviderId": message_source,
108
+ }
109
+ elif message_type == AmazonSequenceType.TextCommand:
110
+ payload = {
111
+ **base_payload,
112
+ "skillId": "amzn1.ask.1p.tellalexa",
113
+ "text": message_body,
114
+ }
115
+ elif message_type == AmazonSequenceType.LaunchSkill:
116
+ payload = {
117
+ **base_payload,
118
+ "targetDevice": {
119
+ "deviceType": device.device_type,
120
+ "deviceSerialNumber": device.serial_number,
121
+ },
122
+ "connectionRequest": {
123
+ "uri": "connection://AMAZON.Launch/" + message_body,
124
+ },
125
+ }
126
+ elif message_type in ALEXA_INFO_SKILLS:
127
+ payload = {
128
+ **base_payload,
129
+ }
130
+ else:
131
+ raise ValueError(f"Message type <{message_type}> is not recognised")
132
+
133
+ sequence = {
134
+ "@type": "com.amazon.alexa.behaviors.model.Sequence",
135
+ "startNode": {
136
+ "@type": "com.amazon.alexa.behaviors.model.SerialNode",
137
+ "nodesToExecute": [
138
+ {
139
+ "@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode", # noqa: E501
140
+ "type": message_type,
141
+ "operationPayload": payload,
142
+ },
143
+ ],
144
+ },
145
+ }
146
+
147
+ node_data = {
148
+ "behaviorId": "PREVIEW",
149
+ "sequenceJson": orjson.dumps(sequence).decode("utf-8"),
150
+ "status": "ENABLED",
151
+ }
152
+
153
+ _LOGGER.debug("Preview data payload: %s", node_data)
154
+ await self._http_wrapper.session_request(
155
+ method=HTTPMethod.POST,
156
+ url=f"https://alexa.amazon.{self._session_state_data.domain}/api/behaviors/preview",
157
+ input_data=node_data,
158
+ json_data=True,
159
+ )
@@ -37,7 +37,7 @@ class AmazonDevice:
37
37
  device_type: str
38
38
  device_owner_customer_id: str
39
39
  household_device: bool
40
- device_cluster_members: list[str]
40
+ device_cluster_members: dict[str, str | None]
41
41
  online: bool
42
42
  serial_number: str
43
43
  software_version: str