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.
- aioamazondevices/__init__.py +1 -1
- aioamazondevices/api.py +150 -1213
- aioamazondevices/const/__init__.py +1 -0
- aioamazondevices/{const.py → const/devices.py} +54 -161
- aioamazondevices/const/http.py +37 -0
- aioamazondevices/const/metadata.py +46 -0
- aioamazondevices/{query.py → const/queries.py} +1 -1
- aioamazondevices/const/schedules.py +61 -0
- aioamazondevices/{sounds.py → const/sounds.py} +2 -1
- aioamazondevices/http_wrapper.py +422 -0
- aioamazondevices/implementation/__init__.py +1 -0
- aioamazondevices/implementation/dnd.py +56 -0
- aioamazondevices/implementation/notification.py +224 -0
- aioamazondevices/implementation/sequence.py +159 -0
- aioamazondevices/login.py +439 -0
- aioamazondevices/structures.py +65 -0
- aioamazondevices/utils.py +23 -1
- {aioamazondevices-6.5.1.dist-info → aioamazondevices-11.0.2.dist-info}/METADATA +17 -4
- aioamazondevices-11.0.2.dist-info/RECORD +23 -0
- aioamazondevices-6.5.1.dist-info/RECORD +0 -12
- {aioamazondevices-6.5.1.dist-info → aioamazondevices-11.0.2.dist-info}/WHEEL +0 -0
- {aioamazondevices-6.5.1.dist-info → aioamazondevices-11.0.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
+
)
|