aioamazondevices 9.0.2__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.
@@ -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
- agent: str = "Amazon",
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[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
+ )