aioamazondevices 9.0.3__tar.gz → 11.0.1__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.
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/PKG-INFO +1 -1
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/pyproject.toml +1 -1
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/src/aioamazondevices/__init__.py +1 -1
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/src/aioamazondevices/api.py +61 -394
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/src/aioamazondevices/const/devices.py +9 -0
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/src/aioamazondevices/const/http.py +3 -2
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/src/aioamazondevices/http_wrapper.py +64 -3
- aioamazondevices-11.0.1/src/aioamazondevices/implementation/__init__.py +1 -0
- aioamazondevices-11.0.1/src/aioamazondevices/implementation/dnd.py +56 -0
- aioamazondevices-11.0.1/src/aioamazondevices/implementation/notification.py +224 -0
- aioamazondevices-11.0.1/src/aioamazondevices/implementation/sequence.py +159 -0
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/src/aioamazondevices/login.py +1 -57
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/src/aioamazondevices/structures.py +1 -1
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/LICENSE +0 -0
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/README.md +0 -0
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/src/aioamazondevices/const/__init__.py +0 -0
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/src/aioamazondevices/const/metadata.py +0 -0
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/src/aioamazondevices/const/queries.py +0 -0
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/src/aioamazondevices/const/schedules.py +0 -0
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/src/aioamazondevices/const/sounds.py +0 -0
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/src/aioamazondevices/exceptions.py +0 -0
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/src/aioamazondevices/py.typed +0 -0
- {aioamazondevices-9.0.3 → aioamazondevices-11.0.1}/src/aioamazondevices/utils.py +0 -0
|
@@ -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 (
|
|
@@ -19,33 +16,29 @@ from .const.devices import (
|
|
|
19
16
|
from .const.http import (
|
|
20
17
|
ARRAY_WRAPPER,
|
|
21
18
|
DEFAULT_SITE,
|
|
19
|
+
REQUEST_AGENT,
|
|
22
20
|
URI_DEVICES,
|
|
23
|
-
URI_DND,
|
|
24
21
|
URI_NEXUS_GRAPHQL,
|
|
25
|
-
URI_NOTIFICATIONS,
|
|
26
22
|
)
|
|
27
|
-
from .const.metadata import
|
|
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.dnd import AmazonDnDHandler
|
|
35
|
+
from .implementation.notification import AmazonNotificationHandler
|
|
36
|
+
from .implementation.sequence import AmazonSequenceHandler
|
|
43
37
|
from .login import AmazonLogin
|
|
44
38
|
from .structures import (
|
|
45
39
|
AmazonDevice,
|
|
46
40
|
AmazonDeviceSensor,
|
|
47
41
|
AmazonMusicSource,
|
|
48
|
-
AmazonSchedule,
|
|
49
42
|
AmazonSequenceType,
|
|
50
43
|
)
|
|
51
44
|
from .utils import _LOGGER
|
|
@@ -85,7 +78,19 @@ class AmazonEchoApi:
|
|
|
85
78
|
session_state_data=self._session_state_data,
|
|
86
79
|
)
|
|
87
80
|
|
|
88
|
-
self.
|
|
81
|
+
self._notification_handler = AmazonNotificationHandler(
|
|
82
|
+
http_wrapper=self._http_wrapper,
|
|
83
|
+
session_state_data=self._session_state_data,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
self._sequence_handler = AmazonSequenceHandler(
|
|
87
|
+
http_wrapper=self._http_wrapper,
|
|
88
|
+
session_state_data=self._session_state_data,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
self._dnd_handler = AmazonDnDHandler(
|
|
92
|
+
http_wrapper=self._http_wrapper, session_state_data=self._session_state_data
|
|
93
|
+
)
|
|
89
94
|
|
|
90
95
|
self._final_devices: dict[str, AmazonDevice] = {}
|
|
91
96
|
self._endpoints: dict[str, str] = {} # endpoint ID to serial number map
|
|
@@ -127,6 +132,7 @@ class AmazonEchoApi:
|
|
|
127
132
|
url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_NEXUS_GRAPHQL}",
|
|
128
133
|
input_data=payload,
|
|
129
134
|
json_data=True,
|
|
135
|
+
extended_headers={"User-Agent": REQUEST_AGENT["Amazon"]},
|
|
130
136
|
)
|
|
131
137
|
|
|
132
138
|
sensors_state = await self._http_wrapper.response_to_json(raw_resp, "sensors")
|
|
@@ -236,6 +242,7 @@ class AmazonEchoApi:
|
|
|
236
242
|
url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_NEXUS_GRAPHQL}",
|
|
237
243
|
input_data=payload,
|
|
238
244
|
json_data=True,
|
|
245
|
+
extended_headers={"User-Agent": REQUEST_AGENT["Amazon"]},
|
|
239
246
|
)
|
|
240
247
|
|
|
241
248
|
endpoint_data = await self._http_wrapper.response_to_json(raw_resp, "endpoint")
|
|
@@ -258,190 +265,6 @@ class AmazonEchoApi:
|
|
|
258
265
|
|
|
259
266
|
return devices_endpoints
|
|
260
267
|
|
|
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
268
|
async def get_devices_data(
|
|
446
269
|
self,
|
|
447
270
|
) -> dict[str, AmazonDevice]:
|
|
@@ -477,8 +300,8 @@ class AmazonEchoApi:
|
|
|
477
300
|
|
|
478
301
|
async def _get_sensor_data(self) -> None:
|
|
479
302
|
devices_sensors = await self._get_sensors_states()
|
|
480
|
-
dnd_sensors = await self.
|
|
481
|
-
notifications = await self.
|
|
303
|
+
dnd_sensors = await self._dnd_handler.get_do_not_disturb_status()
|
|
304
|
+
notifications = await self._notification_handler.get_notifications()
|
|
482
305
|
for device in self._final_devices.values():
|
|
483
306
|
# Update sensors
|
|
484
307
|
sensors = devices_sensors.get(device.serial_number, {})
|
|
@@ -542,6 +365,7 @@ class AmazonEchoApi:
|
|
|
542
365
|
json_data = await self._http_wrapper.response_to_json(raw_resp, "devices")
|
|
543
366
|
|
|
544
367
|
final_devices_list: dict[str, AmazonDevice] = {}
|
|
368
|
+
serial_to_device_type: dict[str, str] = {}
|
|
545
369
|
for device in json_data["devices"]:
|
|
546
370
|
# Remove stale, orphaned and virtual devices
|
|
547
371
|
if not device or (device.get("deviceType") in DEVICE_TO_IGNORE):
|
|
@@ -566,7 +390,9 @@ class AmazonEchoApi:
|
|
|
566
390
|
device_owner_customer_id=device["deviceOwnerCustomerId"],
|
|
567
391
|
household_device=device["deviceOwnerCustomerId"]
|
|
568
392
|
== self._session_state_data.account_customer_id,
|
|
569
|
-
device_cluster_members=(
|
|
393
|
+
device_cluster_members=dict.fromkeys(
|
|
394
|
+
device["clusterMembers"] or [serial_number]
|
|
395
|
+
),
|
|
570
396
|
online=device["online"],
|
|
571
397
|
serial_number=serial_number,
|
|
572
398
|
software_version=device["softwareVersion"],
|
|
@@ -576,12 +402,14 @@ class AmazonEchoApi:
|
|
|
576
402
|
notifications={},
|
|
577
403
|
)
|
|
578
404
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
405
|
+
serial_to_device_type[serial_number] = device["deviceType"]
|
|
406
|
+
|
|
407
|
+
# backfill device types for cluster members
|
|
408
|
+
for device in final_devices_list.values():
|
|
409
|
+
for member_serial in device.device_cluster_members:
|
|
410
|
+
device.device_cluster_members[member_serial] = (
|
|
411
|
+
serial_to_device_type.get(member_serial)
|
|
412
|
+
)
|
|
585
413
|
|
|
586
414
|
self._final_devices = final_devices_list
|
|
587
415
|
|
|
@@ -599,239 +427,74 @@ class AmazonEchoApi:
|
|
|
599
427
|
|
|
600
428
|
return model_details
|
|
601
429
|
|
|
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
430
|
async def call_alexa_speak(
|
|
737
431
|
self,
|
|
738
432
|
device: AmazonDevice,
|
|
739
|
-
|
|
433
|
+
text_to_speak: str,
|
|
740
434
|
) -> None:
|
|
741
435
|
"""Call Alexa.Speak to send a message."""
|
|
742
|
-
|
|
436
|
+
await self._sequence_handler.send_message(
|
|
437
|
+
device, AmazonSequenceType.Speak, text_to_speak
|
|
438
|
+
)
|
|
743
439
|
|
|
744
440
|
async def call_alexa_announcement(
|
|
745
441
|
self,
|
|
746
442
|
device: AmazonDevice,
|
|
747
|
-
|
|
443
|
+
text_to_announce: str,
|
|
748
444
|
) -> None:
|
|
749
445
|
"""Call AlexaAnnouncement to send a message."""
|
|
750
|
-
|
|
751
|
-
device, AmazonSequenceType.Announcement,
|
|
446
|
+
await self._sequence_handler.send_message(
|
|
447
|
+
device, AmazonSequenceType.Announcement, text_to_announce
|
|
752
448
|
)
|
|
753
449
|
|
|
754
450
|
async def call_alexa_sound(
|
|
755
451
|
self,
|
|
756
452
|
device: AmazonDevice,
|
|
757
|
-
|
|
453
|
+
sound_name: str,
|
|
758
454
|
) -> None:
|
|
759
455
|
"""Call Alexa.Sound to play sound."""
|
|
760
|
-
|
|
456
|
+
await self._sequence_handler.send_message(
|
|
457
|
+
device, AmazonSequenceType.Sound, sound_name
|
|
458
|
+
)
|
|
761
459
|
|
|
762
460
|
async def call_alexa_music(
|
|
763
461
|
self,
|
|
764
462
|
device: AmazonDevice,
|
|
765
|
-
|
|
766
|
-
|
|
463
|
+
search_phrase: str,
|
|
464
|
+
music_source: AmazonMusicSource,
|
|
767
465
|
) -> None:
|
|
768
466
|
"""Call Alexa.Music.PlaySearchPhrase to play music."""
|
|
769
|
-
|
|
770
|
-
device, AmazonSequenceType.Music,
|
|
467
|
+
await self._sequence_handler.send_message(
|
|
468
|
+
device, AmazonSequenceType.Music, search_phrase, music_source
|
|
771
469
|
)
|
|
772
470
|
|
|
773
471
|
async def call_alexa_text_command(
|
|
774
472
|
self,
|
|
775
473
|
device: AmazonDevice,
|
|
776
|
-
|
|
474
|
+
text_command: str,
|
|
777
475
|
) -> None:
|
|
778
476
|
"""Call Alexa.TextCommand to issue command."""
|
|
779
|
-
|
|
780
|
-
device, AmazonSequenceType.TextCommand,
|
|
477
|
+
await self._sequence_handler.send_message(
|
|
478
|
+
device, AmazonSequenceType.TextCommand, text_command
|
|
781
479
|
)
|
|
782
480
|
|
|
783
481
|
async def call_alexa_skill(
|
|
784
482
|
self,
|
|
785
483
|
device: AmazonDevice,
|
|
786
|
-
|
|
484
|
+
skill_name: str,
|
|
787
485
|
) -> None:
|
|
788
486
|
"""Call Alexa.LaunchSkill to launch a skill."""
|
|
789
|
-
|
|
790
|
-
device, AmazonSequenceType.LaunchSkill,
|
|
487
|
+
await self._sequence_handler.send_message(
|
|
488
|
+
device, AmazonSequenceType.LaunchSkill, skill_name
|
|
791
489
|
)
|
|
792
490
|
|
|
793
491
|
async def call_alexa_info_skill(
|
|
794
492
|
self,
|
|
795
493
|
device: AmazonDevice,
|
|
796
|
-
|
|
494
|
+
info_skill_name: str,
|
|
797
495
|
) -> None:
|
|
798
496
|
"""Call Info skill. See ALEXA_INFO_SKILLS . const."""
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
async def set_do_not_disturb(self, device: AmazonDevice, state: bool) -> None:
|
|
802
|
-
"""Set do_not_disturb flag."""
|
|
803
|
-
payload = {
|
|
804
|
-
"deviceSerialNumber": device.serial_number,
|
|
805
|
-
"deviceType": device.device_type,
|
|
806
|
-
"enabled": state,
|
|
807
|
-
}
|
|
808
|
-
url = f"https://alexa.amazon.{self._session_state_data.domain}/api/dnd/status"
|
|
809
|
-
await self._http_wrapper.session_request(
|
|
810
|
-
method="PUT",
|
|
811
|
-
url=url,
|
|
812
|
-
input_data=payload,
|
|
813
|
-
json_data=True,
|
|
814
|
-
)
|
|
815
|
-
|
|
816
|
-
async def _get_dnd_status(self) -> dict[str, AmazonDeviceSensor]:
|
|
817
|
-
dnd_status: dict[str, AmazonDeviceSensor] = {}
|
|
818
|
-
_, raw_resp = await self._http_wrapper.session_request(
|
|
819
|
-
method=HTTPMethod.GET,
|
|
820
|
-
url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_DND}",
|
|
821
|
-
)
|
|
822
|
-
|
|
823
|
-
dnd_data = await self._http_wrapper.response_to_json(raw_resp, "dnd")
|
|
824
|
-
|
|
825
|
-
for dnd in dnd_data.get("doNotDisturbDeviceStatusList", {}):
|
|
826
|
-
dnd_status[dnd.get("deviceSerialNumber")] = AmazonDeviceSensor(
|
|
827
|
-
name="dnd",
|
|
828
|
-
value=dnd.get("enabled"),
|
|
829
|
-
error=False,
|
|
830
|
-
error_type=None,
|
|
831
|
-
error_msg=None,
|
|
832
|
-
scale=None,
|
|
833
|
-
)
|
|
834
|
-
return dnd_status
|
|
497
|
+
await self._sequence_handler.send_message(device, info_skill_name, "")
|
|
835
498
|
|
|
836
499
|
async def _format_human_error(self, sensors_state: dict) -> bool:
|
|
837
500
|
"""Format human readable error from malformed data."""
|
|
@@ -847,3 +510,7 @@ class AmazonEchoApi:
|
|
|
847
510
|
path = error[0].get("path", "Unknown path")
|
|
848
511
|
_LOGGER.error("Error retrieving devices state: %s for path %s", msg, path)
|
|
849
512
|
return True
|
|
513
|
+
|
|
514
|
+
async def set_do_not_disturb(self, device: AmazonDevice, enable: bool) -> None:
|
|
515
|
+
"""Set Do Not Disturb status for a device."""
|
|
516
|
+
await self._dnd_handler.set_do_not_disturb(device, enable)
|
|
@@ -417,4 +417,13 @@ DEVICE_TYPE_TO_MODEL: dict[str, dict[str, str | None]] = {
|
|
|
417
417
|
"model": "Ford SYNC 4",
|
|
418
418
|
"hw_version": None,
|
|
419
419
|
},
|
|
420
|
+
"A1J16TEDOYCZTN": {
|
|
421
|
+
"model": "Fire Tablet 7",
|
|
422
|
+
"hw_version": "Gen7",
|
|
423
|
+
},
|
|
424
|
+
"A18TCD9FP10WJ9": {
|
|
425
|
+
"manufacturer": "Netgear",
|
|
426
|
+
"model": "Orbi Voice (RBS40V)",
|
|
427
|
+
"hw_version": None,
|
|
428
|
+
},
|
|
420
429
|
}
|
|
@@ -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"
|
|
@@ -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
|
)
|
|
@@ -214,13 +218,66 @@ class AmazonHttpWrapper:
|
|
|
214
218
|
|
|
215
219
|
return HTTPStatus(error).phrase
|
|
216
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
|
+
|
|
217
274
|
async def session_request(
|
|
218
275
|
self,
|
|
219
276
|
method: str,
|
|
220
277
|
url: str,
|
|
221
278
|
input_data: dict[str, Any] | list[dict[str, Any]] | None = None,
|
|
222
279
|
json_data: bool = False,
|
|
223
|
-
|
|
280
|
+
extended_headers: dict[str, str] | None = None,
|
|
224
281
|
) -> tuple[BeautifulSoup, ClientResponse]:
|
|
225
282
|
"""Return request response context data."""
|
|
226
283
|
_LOGGER.debug(
|
|
@@ -232,11 +289,15 @@ class AmazonHttpWrapper:
|
|
|
232
289
|
)
|
|
233
290
|
|
|
234
291
|
headers = DEFAULT_HEADERS.copy()
|
|
235
|
-
headers.update({"User-Agent": REQUEST_AGENT[
|
|
292
|
+
headers.update({"User-Agent": REQUEST_AGENT["Browser"]})
|
|
236
293
|
headers.update({"Accept-Language": self._session_state_data.language})
|
|
237
294
|
headers.update({"x-amzn-client": "github.com/chemelli74/aioamazondevices"})
|
|
238
295
|
headers.update({"x-amzn-build-version": __version__})
|
|
239
296
|
|
|
297
|
+
if extended_headers:
|
|
298
|
+
_LOGGER.debug("Adding to headers: %s", extended_headers)
|
|
299
|
+
headers.update(extended_headers)
|
|
300
|
+
|
|
240
301
|
if self._csrf_cookie:
|
|
241
302
|
csrf = {CSRF_COOKIE: self._csrf_cookie}
|
|
242
303
|
_LOGGER.debug("Adding to headers: %s", csrf)
|
|
@@ -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
|
+
)
|
|
@@ -10,20 +10,17 @@ from http import HTTPMethod, HTTPStatus
|
|
|
10
10
|
from typing import Any, cast
|
|
11
11
|
from urllib.parse import parse_qs, urlencode
|
|
12
12
|
|
|
13
|
-
import orjson
|
|
14
13
|
from bs4 import BeautifulSoup, Tag
|
|
15
14
|
from multidict import MultiDictProxy
|
|
16
15
|
from yarl import URL
|
|
17
16
|
|
|
18
17
|
from .const.http import (
|
|
19
|
-
AMAZON_APP_BUNDLE_ID,
|
|
20
18
|
AMAZON_APP_NAME,
|
|
21
19
|
AMAZON_APP_VERSION,
|
|
22
20
|
AMAZON_CLIENT_OS,
|
|
23
21
|
AMAZON_DEVICE_SOFTWARE_VERSION,
|
|
24
22
|
AMAZON_DEVICE_TYPE,
|
|
25
23
|
DEFAULT_SITE,
|
|
26
|
-
REFRESH_ACCESS_TOKEN,
|
|
27
24
|
REFRESH_AUTH_COOKIES,
|
|
28
25
|
URI_DEVICES,
|
|
29
26
|
URI_SIGNIN,
|
|
@@ -362,7 +359,7 @@ class AmazonLogin:
|
|
|
362
359
|
|
|
363
360
|
async def _refresh_auth_cookies(self) -> None:
|
|
364
361
|
"""Refresh cookies after domain swap."""
|
|
365
|
-
_, json_token_resp = await self.
|
|
362
|
+
_, json_token_resp = await self._http_wrapper.refresh_data(REFRESH_AUTH_COOKIES)
|
|
366
363
|
|
|
367
364
|
# Need to take cookies from response and create them as cookies
|
|
368
365
|
website_cookies = self._session_state_data.login_stored_data[
|
|
@@ -394,59 +391,6 @@ class AmazonLogin:
|
|
|
394
391
|
await self._http_wrapper.clear_csrf_cookie()
|
|
395
392
|
await self._refresh_auth_cookies()
|
|
396
393
|
|
|
397
|
-
async def _refresh_data(self, data_type: str) -> tuple[bool, dict]:
|
|
398
|
-
"""Refresh data."""
|
|
399
|
-
if not self._session_state_data.login_stored_data:
|
|
400
|
-
_LOGGER.debug("No login data available, cannot refresh")
|
|
401
|
-
return False, {}
|
|
402
|
-
|
|
403
|
-
data = {
|
|
404
|
-
"app_name": AMAZON_APP_NAME,
|
|
405
|
-
"app_version": AMAZON_APP_VERSION,
|
|
406
|
-
"di.sdk.version": "6.12.4",
|
|
407
|
-
"source_token": self._session_state_data.login_stored_data["refresh_token"],
|
|
408
|
-
"package_name": AMAZON_APP_BUNDLE_ID,
|
|
409
|
-
"di.hw.version": "iPhone",
|
|
410
|
-
"platform": "iOS",
|
|
411
|
-
"requested_token_type": data_type,
|
|
412
|
-
"source_token_type": "refresh_token",
|
|
413
|
-
"di.os.name": "iOS",
|
|
414
|
-
"di.os.version": AMAZON_CLIENT_OS,
|
|
415
|
-
"current_version": "6.12.4",
|
|
416
|
-
"previous_version": "6.12.4",
|
|
417
|
-
"domain": f"www.amazon.{self._session_state_data.domain}",
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
_, raw_resp = await self._http_wrapper.session_request(
|
|
421
|
-
method=HTTPMethod.POST,
|
|
422
|
-
url="https://api.amazon.com/auth/token",
|
|
423
|
-
input_data=data,
|
|
424
|
-
json_data=False,
|
|
425
|
-
)
|
|
426
|
-
_LOGGER.debug(
|
|
427
|
-
"Refresh data response %s with payload %s",
|
|
428
|
-
raw_resp.status,
|
|
429
|
-
orjson.dumps(data),
|
|
430
|
-
)
|
|
431
|
-
|
|
432
|
-
if raw_resp.status != HTTPStatus.OK:
|
|
433
|
-
_LOGGER.debug("Failed to refresh data")
|
|
434
|
-
return False, {}
|
|
435
|
-
|
|
436
|
-
json_response = await self._http_wrapper.response_to_json(raw_resp, data_type)
|
|
437
|
-
|
|
438
|
-
if data_type == REFRESH_ACCESS_TOKEN and (
|
|
439
|
-
new_token := json_response.get(REFRESH_ACCESS_TOKEN)
|
|
440
|
-
):
|
|
441
|
-
self._session_state_data.login_stored_data[REFRESH_ACCESS_TOKEN] = new_token
|
|
442
|
-
return True, json_response
|
|
443
|
-
|
|
444
|
-
if data_type == REFRESH_AUTH_COOKIES:
|
|
445
|
-
return True, json_response
|
|
446
|
-
|
|
447
|
-
_LOGGER.debug("Unexpected refresh data response")
|
|
448
|
-
return False, {}
|
|
449
|
-
|
|
450
394
|
async def obtain_account_customer_id(self) -> None:
|
|
451
395
|
"""Find account customer id."""
|
|
452
396
|
for retry_count in range(MAX_CUSTOMER_ACCOUNT_RETRIES):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|