aioamazondevices 6.5.1__py3-none-any.whl → 11.0.2__py3-none-any.whl

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.
@@ -0,0 +1,224 @@
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 REQUEST_AGENT, 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
+ extended_headers={"User-Agent": REQUEST_AGENT["Browser"]},
49
+ )
50
+ except CannotRetrieveData:
51
+ _LOGGER.warning(
52
+ "Failed to obtain notification data. Timers and alarms have not been updated" # noqa: E501
53
+ )
54
+ return None
55
+
56
+ notifications = await self._http_wrapper.response_to_json(
57
+ raw_resp, "notifications"
58
+ )
59
+
60
+ for schedule in notifications["notifications"]:
61
+ schedule_type: str = schedule["type"]
62
+ schedule_device_type = schedule["deviceType"]
63
+ schedule_device_serial = schedule["deviceSerialNumber"]
64
+
65
+ if schedule_device_type in DEVICE_TO_IGNORE:
66
+ continue
67
+
68
+ if schedule_type not in NOTIFICATIONS_SUPPORTED:
69
+ _LOGGER.debug(
70
+ "Unsupported schedule type %s for device %s",
71
+ schedule_type,
72
+ schedule_device_serial,
73
+ )
74
+ continue
75
+
76
+ if schedule_type == NOTIFICATION_MUSIC_ALARM:
77
+ # Structure is the same as standard Alarm
78
+ schedule_type = NOTIFICATION_ALARM
79
+ schedule["type"] = NOTIFICATION_ALARM
80
+ label_desc = schedule_type.lower() + "Label"
81
+ if (schedule_status := schedule["status"]) == "ON" and (
82
+ next_occurrence := await self._parse_next_occurrence(schedule)
83
+ ):
84
+ schedule_notification_list = final_notifications.get(
85
+ schedule_device_serial, {}
86
+ )
87
+ schedule_notification_by_type = schedule_notification_list.get(
88
+ schedule_type
89
+ )
90
+ # Replace if no existing notification
91
+ # or if existing.next_occurrence is None
92
+ # or if new next_occurrence is earlier
93
+ if (
94
+ not schedule_notification_by_type
95
+ or schedule_notification_by_type.next_occurrence is None
96
+ or next_occurrence < schedule_notification_by_type.next_occurrence
97
+ ):
98
+ final_notifications.update(
99
+ {
100
+ schedule_device_serial: {
101
+ **schedule_notification_list
102
+ | {
103
+ schedule_type: AmazonSchedule(
104
+ type=schedule_type,
105
+ status=schedule_status,
106
+ label=schedule[label_desc],
107
+ next_occurrence=next_occurrence,
108
+ ),
109
+ }
110
+ }
111
+ }
112
+ )
113
+
114
+ return final_notifications
115
+
116
+ async def _parse_next_occurrence(
117
+ self,
118
+ schedule: dict[str, Any],
119
+ ) -> datetime | None:
120
+ """Parse RFC5545 rule set for next iteration."""
121
+ # Local timezone
122
+ tzinfo = datetime.now().astimezone().tzinfo
123
+ # Current time
124
+ actual_time = datetime.now(tz=tzinfo)
125
+ # Reference start date
126
+ today_midnight = actual_time.replace(hour=0, minute=0, second=0, microsecond=0)
127
+ # Reference time (1 minute ago to avoid edge cases)
128
+ now_reference = actual_time - timedelta(minutes=1)
129
+
130
+ # Schedule data
131
+ original_date = schedule.get("originalDate")
132
+ original_time = schedule.get("originalTime")
133
+
134
+ recurring_rules: list[str] = []
135
+ if schedule.get("rRuleData"):
136
+ recurring_rules = schedule["rRuleData"]["recurrenceRules"]
137
+ if schedule.get("recurringPattern"):
138
+ recurring_rules.append(schedule["recurringPattern"])
139
+
140
+ # Recurring events
141
+ if recurring_rules:
142
+ next_candidates: list[datetime] = []
143
+ for recurring_rule in recurring_rules:
144
+ # Already in RFC5545 format
145
+ if "FREQ=" in recurring_rule:
146
+ rule = await self._add_hours_minutes(recurring_rule, original_time)
147
+
148
+ # Add date to candidates list
149
+ next_candidates.append(
150
+ rrulestr(rule, dtstart=today_midnight).after(
151
+ now_reference, True
152
+ ),
153
+ )
154
+ continue
155
+
156
+ if recurring_rule not in RECURRING_PATTERNS:
157
+ _LOGGER.warning(
158
+ "Unknown recurring rule <%s> for schedule type <%s>",
159
+ recurring_rule,
160
+ schedule["type"],
161
+ )
162
+ return None
163
+
164
+ # Adjust recurring rules for country specific weekend exceptions
165
+ recurring_pattern = RECURRING_PATTERNS.copy()
166
+ for group, countries in COUNTRY_GROUPS.items():
167
+ if self._session_state_data.country_code in countries:
168
+ recurring_pattern |= WEEKEND_EXCEPTIONS[group]
169
+ break
170
+
171
+ rule = await self._add_hours_minutes(
172
+ recurring_pattern[recurring_rule], original_time
173
+ )
174
+
175
+ # Add date to candidates list
176
+ next_candidates.append(
177
+ rrulestr(rule, dtstart=today_midnight).after(now_reference, True),
178
+ )
179
+
180
+ return min(next_candidates) if next_candidates else None
181
+
182
+ # Single events
183
+ if schedule["type"] == NOTIFICATION_ALARM:
184
+ timestamp = parse(f"{original_date} {original_time}").replace(tzinfo=tzinfo)
185
+
186
+ elif schedule["type"] == NOTIFICATION_TIMER:
187
+ # API returns triggerTime in milliseconds since epoch
188
+ timestamp = datetime.fromtimestamp(
189
+ schedule["triggerTime"] / 1000, tz=tzinfo
190
+ )
191
+
192
+ elif schedule["type"] == NOTIFICATION_REMINDER:
193
+ # API returns alarmTime in milliseconds since epoch
194
+ timestamp = datetime.fromtimestamp(schedule["alarmTime"] / 1000, tz=tzinfo)
195
+
196
+ else:
197
+ _LOGGER.warning(("Unknown schedule type: %s"), schedule["type"])
198
+ return None
199
+
200
+ if timestamp > now_reference:
201
+ return timestamp
202
+
203
+ return None
204
+
205
+ async def _add_hours_minutes(
206
+ self,
207
+ recurring_rule: str,
208
+ original_time: str | None,
209
+ ) -> str:
210
+ """Add hours and minutes to a RFC5545 string."""
211
+ rule = recurring_rule.removesuffix(";")
212
+
213
+ if not original_time:
214
+ return rule
215
+
216
+ # Add missing BYHOUR, BYMINUTE if needed (Alarms only)
217
+ if "BYHOUR=" not in recurring_rule:
218
+ hour = int(original_time.split(":")[0])
219
+ rule += f";BYHOUR={hour}"
220
+ if "BYMINUTE=" not in recurring_rule:
221
+ minute = int(original_time.split(":")[1])
222
+ rule += f";BYMINUTE={minute}"
223
+
224
+ 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
+ )