aioamazondevices 9.0.2__py3-none-any.whl → 11.0.3__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 +80 -428
- aioamazondevices/const/devices.py +9 -0
- aioamazondevices/const/http.py +3 -2
- aioamazondevices/const/metadata.py +8 -0
- aioamazondevices/http_wrapper.py +81 -8
- 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 +53 -59
- aioamazondevices/structures.py +1 -1
- {aioamazondevices-9.0.2.dist-info → aioamazondevices-11.0.3.dist-info}/METADATA +1 -1
- aioamazondevices-11.0.3.dist-info/RECORD +23 -0
- aioamazondevices-9.0.2.dist-info/RECORD +0 -19
- {aioamazondevices-9.0.2.dist-info → aioamazondevices-11.0.3.dist-info}/WHEEL +0 -0
- {aioamazondevices-9.0.2.dist-info → aioamazondevices-11.0.3.dist-info}/licenses/LICENSE +0 -0
aioamazondevices/const/http.py
CHANGED
|
@@ -30,7 +30,8 @@ REFRESH_ACCESS_TOKEN = "access_token" # noqa: S105
|
|
|
30
30
|
REFRESH_AUTH_COOKIES = "auth_cookies"
|
|
31
31
|
|
|
32
32
|
URI_DEVICES = "/api/devices-v2/device"
|
|
33
|
-
|
|
33
|
+
URI_DND_STATUS_ALL = "/api/dnd/device-status-list"
|
|
34
|
+
URI_DND_STATUS_DEVICE = "/api/dnd/status"
|
|
35
|
+
URI_NEXUS_GRAPHQL = "/nexus/v1/graphql"
|
|
34
36
|
URI_NOTIFICATIONS = "/api/notifications"
|
|
35
37
|
URI_SIGNIN = "/ap/signin"
|
|
36
|
-
URI_NEXUS_GRAPHQL = "/nexus/v1/graphql"
|
|
@@ -21,6 +21,12 @@ SENSORS: dict[str, dict[str, str | None]] = {
|
|
|
21
21
|
"subkey": "value",
|
|
22
22
|
"scale": None,
|
|
23
23
|
},
|
|
24
|
+
"connectivity": {
|
|
25
|
+
"name": "reachability",
|
|
26
|
+
"key": "reachabilityStatusValue",
|
|
27
|
+
"subkey": None,
|
|
28
|
+
"scale": None,
|
|
29
|
+
},
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
ALEXA_INFO_SKILLS = [
|
|
@@ -42,3 +48,5 @@ ALEXA_INFO_SKILLS = [
|
|
|
42
48
|
"Alexa.ImHome.Play",
|
|
43
49
|
"Alexa.GoodNight.Play",
|
|
44
50
|
]
|
|
51
|
+
|
|
52
|
+
MAX_CUSTOMER_ACCOUNT_RETRIES = 3
|
aioamazondevices/http_wrapper.py
CHANGED
|
@@ -4,7 +4,7 @@ import asyncio
|
|
|
4
4
|
import base64
|
|
5
5
|
import secrets
|
|
6
6
|
from collections.abc import Callable, Coroutine
|
|
7
|
-
from http import HTTPStatus
|
|
7
|
+
from http import HTTPMethod, HTTPStatus
|
|
8
8
|
from http.cookies import Morsel
|
|
9
9
|
from typing import Any, cast
|
|
10
10
|
|
|
@@ -23,13 +23,17 @@ from . import __version__
|
|
|
23
23
|
from .const.http import (
|
|
24
24
|
AMAZON_APP_BUNDLE_ID,
|
|
25
25
|
AMAZON_APP_ID,
|
|
26
|
+
AMAZON_APP_NAME,
|
|
26
27
|
AMAZON_APP_VERSION,
|
|
28
|
+
AMAZON_CLIENT_OS,
|
|
27
29
|
AMAZON_DEVICE_SOFTWARE_VERSION,
|
|
28
30
|
ARRAY_WRAPPER,
|
|
29
31
|
CSRF_COOKIE,
|
|
30
32
|
DEFAULT_HEADERS,
|
|
31
33
|
HTTP_ERROR_199,
|
|
32
34
|
HTTP_ERROR_299,
|
|
35
|
+
REFRESH_ACCESS_TOKEN,
|
|
36
|
+
REFRESH_AUTH_COOKIES,
|
|
33
37
|
REQUEST_AGENT,
|
|
34
38
|
URI_SIGNIN,
|
|
35
39
|
)
|
|
@@ -56,6 +60,7 @@ class AmazonSessionStateData:
|
|
|
56
60
|
self._login_password: str = login_password
|
|
57
61
|
self._login_stored_data: dict[str, Any] = login_data or {}
|
|
58
62
|
self.country_specific_data(domain)
|
|
63
|
+
self._account_customer_id: str | None = None
|
|
59
64
|
|
|
60
65
|
@property
|
|
61
66
|
def country_code(self) -> str:
|
|
@@ -87,6 +92,21 @@ class AmazonSessionStateData:
|
|
|
87
92
|
"""Return login stored data."""
|
|
88
93
|
return self._login_stored_data
|
|
89
94
|
|
|
95
|
+
@login_stored_data.setter
|
|
96
|
+
def login_stored_data(self, data: dict[str, Any]) -> None:
|
|
97
|
+
"""Set login stored data."""
|
|
98
|
+
self._login_stored_data = data
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def account_customer_id(self) -> str | None:
|
|
102
|
+
"""Return account customer id."""
|
|
103
|
+
return self._account_customer_id
|
|
104
|
+
|
|
105
|
+
@account_customer_id.setter
|
|
106
|
+
def account_customer_id(self, customer_id: str | None) -> None:
|
|
107
|
+
"""Set account customer id."""
|
|
108
|
+
self._account_customer_id = customer_id
|
|
109
|
+
|
|
90
110
|
def country_specific_data(self, domain: str) -> None:
|
|
91
111
|
"""Set country specific data."""
|
|
92
112
|
# Force lower case
|
|
@@ -108,11 +128,6 @@ class AmazonSessionStateData:
|
|
|
108
128
|
self._language,
|
|
109
129
|
)
|
|
110
130
|
|
|
111
|
-
def load_login_stored_data(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
112
|
-
"""Load to Amazon using previously stored data."""
|
|
113
|
-
self._login_stored_data = data
|
|
114
|
-
return self._login_stored_data
|
|
115
|
-
|
|
116
131
|
|
|
117
132
|
class AmazonHttpWrapper:
|
|
118
133
|
"""Amazon HTTP wrapper class."""
|
|
@@ -203,13 +218,66 @@ class AmazonHttpWrapper:
|
|
|
203
218
|
|
|
204
219
|
return HTTPStatus(error).phrase
|
|
205
220
|
|
|
221
|
+
async def refresh_data(self, data_type: str) -> tuple[bool, dict]:
|
|
222
|
+
"""Refresh data."""
|
|
223
|
+
if not self._session_state_data.login_stored_data:
|
|
224
|
+
_LOGGER.debug("No login data available, cannot refresh")
|
|
225
|
+
return False, {}
|
|
226
|
+
|
|
227
|
+
data = {
|
|
228
|
+
"app_name": AMAZON_APP_NAME,
|
|
229
|
+
"app_version": AMAZON_APP_VERSION,
|
|
230
|
+
"di.sdk.version": "6.12.4",
|
|
231
|
+
"source_token": self._session_state_data.login_stored_data["refresh_token"],
|
|
232
|
+
"package_name": AMAZON_APP_BUNDLE_ID,
|
|
233
|
+
"di.hw.version": "iPhone",
|
|
234
|
+
"platform": "iOS",
|
|
235
|
+
"requested_token_type": data_type,
|
|
236
|
+
"source_token_type": "refresh_token",
|
|
237
|
+
"di.os.name": "iOS",
|
|
238
|
+
"di.os.version": AMAZON_CLIENT_OS,
|
|
239
|
+
"current_version": "6.12.4",
|
|
240
|
+
"previous_version": "6.12.4",
|
|
241
|
+
"domain": f"www.amazon.{self._session_state_data.domain}",
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
_, raw_resp = await self.session_request(
|
|
245
|
+
method=HTTPMethod.POST,
|
|
246
|
+
url="https://api.amazon.com/auth/token",
|
|
247
|
+
input_data=data,
|
|
248
|
+
json_data=False,
|
|
249
|
+
)
|
|
250
|
+
_LOGGER.debug(
|
|
251
|
+
"Refresh data response %s with payload %s",
|
|
252
|
+
raw_resp.status,
|
|
253
|
+
orjson.dumps(data),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if raw_resp.status != HTTPStatus.OK:
|
|
257
|
+
_LOGGER.debug("Failed to refresh data")
|
|
258
|
+
return False, {}
|
|
259
|
+
|
|
260
|
+
json_response = await self.response_to_json(raw_resp, data_type)
|
|
261
|
+
|
|
262
|
+
if data_type == REFRESH_ACCESS_TOKEN and (
|
|
263
|
+
new_token := json_response.get(REFRESH_ACCESS_TOKEN)
|
|
264
|
+
):
|
|
265
|
+
self._session_state_data.login_stored_data[REFRESH_ACCESS_TOKEN] = new_token
|
|
266
|
+
return True, json_response
|
|
267
|
+
|
|
268
|
+
if data_type == REFRESH_AUTH_COOKIES:
|
|
269
|
+
return True, json_response
|
|
270
|
+
|
|
271
|
+
_LOGGER.debug("Unexpected refresh data response")
|
|
272
|
+
return False, {}
|
|
273
|
+
|
|
206
274
|
async def session_request(
|
|
207
275
|
self,
|
|
208
276
|
method: str,
|
|
209
277
|
url: str,
|
|
210
278
|
input_data: dict[str, Any] | list[dict[str, Any]] | None = None,
|
|
211
279
|
json_data: bool = False,
|
|
212
|
-
|
|
280
|
+
extended_headers: dict[str, str] | None = None,
|
|
213
281
|
) -> tuple[BeautifulSoup, ClientResponse]:
|
|
214
282
|
"""Return request response context data."""
|
|
215
283
|
_LOGGER.debug(
|
|
@@ -221,11 +289,15 @@ class AmazonHttpWrapper:
|
|
|
221
289
|
)
|
|
222
290
|
|
|
223
291
|
headers = DEFAULT_HEADERS.copy()
|
|
224
|
-
headers.update({"User-Agent": REQUEST_AGENT[
|
|
292
|
+
headers.update({"User-Agent": REQUEST_AGENT["Browser"]})
|
|
225
293
|
headers.update({"Accept-Language": self._session_state_data.language})
|
|
226
294
|
headers.update({"x-amzn-client": "github.com/chemelli74/aioamazondevices"})
|
|
227
295
|
headers.update({"x-amzn-build-version": __version__})
|
|
228
296
|
|
|
297
|
+
if extended_headers:
|
|
298
|
+
_LOGGER.debug("Adding to headers: %s", extended_headers)
|
|
299
|
+
headers.update(extended_headers)
|
|
300
|
+
|
|
229
301
|
if self._csrf_cookie:
|
|
230
302
|
csrf = {CSRF_COOKIE: self._csrf_cookie}
|
|
231
303
|
_LOGGER.debug("Adding to headers: %s", csrf)
|
|
@@ -299,6 +371,7 @@ class AmazonHttpWrapper:
|
|
|
299
371
|
]:
|
|
300
372
|
raise CannotAuthenticate(await self.http_phrase_error(resp.status))
|
|
301
373
|
if not await self._ignore_ap_signin_error(resp):
|
|
374
|
+
_LOGGER.debug("Error response content: %s", await resp.text())
|
|
302
375
|
raise CannotRetrieveData(
|
|
303
376
|
f"Request failed: {await self.http_phrase_error(resp.status)}"
|
|
304
377
|
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""aioamazondevices implementation package."""
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Module to handle Alexa do not disturb setting."""
|
|
2
|
+
|
|
3
|
+
from http import HTTPMethod
|
|
4
|
+
|
|
5
|
+
from aioamazondevices.const.http import URI_DND_STATUS_ALL, URI_DND_STATUS_DEVICE
|
|
6
|
+
from aioamazondevices.http_wrapper import AmazonHttpWrapper, AmazonSessionStateData
|
|
7
|
+
from aioamazondevices.structures import AmazonDevice, AmazonDeviceSensor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AmazonDnDHandler:
|
|
11
|
+
"""Class to handle Alexa Do Not Disturb functionality."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
http_wrapper: AmazonHttpWrapper,
|
|
16
|
+
session_state_data: AmazonSessionStateData,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Initialize AmazonDnDHandler class."""
|
|
19
|
+
self._domain = session_state_data.domain
|
|
20
|
+
self._http_wrapper = http_wrapper
|
|
21
|
+
|
|
22
|
+
async def get_do_not_disturb_status(self) -> dict[str, AmazonDeviceSensor]:
|
|
23
|
+
"""Get do_not_disturb status for all devices."""
|
|
24
|
+
dnd_status: dict[str, AmazonDeviceSensor] = {}
|
|
25
|
+
_, raw_resp = await self._http_wrapper.session_request(
|
|
26
|
+
method=HTTPMethod.GET,
|
|
27
|
+
url=f"https://alexa.amazon.{self._domain}{URI_DND_STATUS_ALL}",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
dnd_data = await self._http_wrapper.response_to_json(raw_resp, "dnd")
|
|
31
|
+
|
|
32
|
+
for dnd in dnd_data.get("doNotDisturbDeviceStatusList", {}):
|
|
33
|
+
dnd_status[dnd.get("deviceSerialNumber")] = AmazonDeviceSensor(
|
|
34
|
+
name="dnd",
|
|
35
|
+
value=dnd.get("enabled"),
|
|
36
|
+
error=False,
|
|
37
|
+
error_type=None,
|
|
38
|
+
error_msg=None,
|
|
39
|
+
scale=None,
|
|
40
|
+
)
|
|
41
|
+
return dnd_status
|
|
42
|
+
|
|
43
|
+
async def set_do_not_disturb(self, device: AmazonDevice, enable: bool) -> None:
|
|
44
|
+
"""Set do_not_disturb flag."""
|
|
45
|
+
payload = {
|
|
46
|
+
"deviceSerialNumber": device.serial_number,
|
|
47
|
+
"deviceType": device.device_type,
|
|
48
|
+
"enabled": enable,
|
|
49
|
+
}
|
|
50
|
+
url = f"https://alexa.amazon.{self._domain}{URI_DND_STATUS_DEVICE}"
|
|
51
|
+
await self._http_wrapper.session_request(
|
|
52
|
+
method=HTTPMethod.PUT,
|
|
53
|
+
url=url,
|
|
54
|
+
input_data=payload,
|
|
55
|
+
json_data=True,
|
|
56
|
+
)
|
|
@@ -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
|
+
)
|