aioamazondevices 8.0.1__tar.gz → 10.0.0__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-8.0.1 → aioamazondevices-10.0.0}/PKG-INFO +10 -3
- {aioamazondevices-8.0.1 → aioamazondevices-10.0.0}/README.md +9 -2
- {aioamazondevices-8.0.1 → aioamazondevices-10.0.0}/pyproject.toml +3 -3
- {aioamazondevices-8.0.1 → aioamazondevices-10.0.0}/src/aioamazondevices/__init__.py +1 -1
- aioamazondevices-10.0.0/src/aioamazondevices/api.py +540 -0
- {aioamazondevices-8.0.1 → aioamazondevices-10.0.0}/src/aioamazondevices/const/devices.py +45 -0
- {aioamazondevices-8.0.1 → aioamazondevices-10.0.0}/src/aioamazondevices/const/metadata.py +2 -0
- aioamazondevices-10.0.0/src/aioamazondevices/http_wrapper.py +360 -0
- aioamazondevices-10.0.0/src/aioamazondevices/implementation/__init__.py +1 -0
- aioamazondevices-10.0.0/src/aioamazondevices/implementation/notification.py +223 -0
- aioamazondevices-10.0.0/src/aioamazondevices/implementation/sequence.py +159 -0
- aioamazondevices-10.0.0/src/aioamazondevices/login.py +495 -0
- {aioamazondevices-8.0.1 → aioamazondevices-10.0.0}/src/aioamazondevices/structures.py +1 -1
- aioamazondevices-8.0.1/src/aioamazondevices/api.py +0 -1489
- {aioamazondevices-8.0.1 → aioamazondevices-10.0.0}/LICENSE +0 -0
- {aioamazondevices-8.0.1 → aioamazondevices-10.0.0}/src/aioamazondevices/const/__init__.py +0 -0
- {aioamazondevices-8.0.1 → aioamazondevices-10.0.0}/src/aioamazondevices/const/http.py +0 -0
- {aioamazondevices-8.0.1 → aioamazondevices-10.0.0}/src/aioamazondevices/const/queries.py +0 -0
- {aioamazondevices-8.0.1 → aioamazondevices-10.0.0}/src/aioamazondevices/const/schedules.py +0 -0
- {aioamazondevices-8.0.1 → aioamazondevices-10.0.0}/src/aioamazondevices/const/sounds.py +0 -0
- {aioamazondevices-8.0.1 → aioamazondevices-10.0.0}/src/aioamazondevices/exceptions.py +0 -0
- {aioamazondevices-8.0.1 → aioamazondevices-10.0.0}/src/aioamazondevices/py.typed +0 -0
- {aioamazondevices-8.0.1 → aioamazondevices-10.0.0}/src/aioamazondevices/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aioamazondevices
|
|
3
|
-
Version:
|
|
3
|
+
Version: 10.0.0
|
|
4
4
|
Summary: Python library to control Amazon devices
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
License-File: LICENSE
|
|
@@ -134,6 +134,13 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
|
|
134
134
|
<sub><b>Ivan F. Martinez</b></sub>
|
|
135
135
|
</a>
|
|
136
136
|
</td>
|
|
137
|
+
<td align="center">
|
|
138
|
+
<a href="https://github.com/eyadkobatte">
|
|
139
|
+
<img src="https://avatars.githubusercontent.com/u/16541074?v=4" width="100;" alt="eyadkobatte"/>
|
|
140
|
+
<br />
|
|
141
|
+
<sub><b>Eyad Kobatte</b></sub>
|
|
142
|
+
</a>
|
|
143
|
+
</td>
|
|
137
144
|
<td align="center">
|
|
138
145
|
<a href="https://github.com/AzonInc">
|
|
139
146
|
<img src="https://avatars.githubusercontent.com/u/11911587?v=4" width="100;" alt="AzonInc"/>
|
|
@@ -141,6 +148,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
|
|
141
148
|
<sub><b>Flo</b></sub>
|
|
142
149
|
</a>
|
|
143
150
|
</td>
|
|
151
|
+
</tr>
|
|
152
|
+
<tr>
|
|
144
153
|
<td align="center">
|
|
145
154
|
<a href="https://github.com/francescolf">
|
|
146
155
|
<img src="https://avatars.githubusercontent.com/u/14892143?v=4" width="100;" alt="francescolf"/>
|
|
@@ -148,8 +157,6 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
|
|
148
157
|
<sub><b>Francesco Lo Faro</b></sub>
|
|
149
158
|
</a>
|
|
150
159
|
</td>
|
|
151
|
-
</tr>
|
|
152
|
-
<tr>
|
|
153
160
|
<td align="center">
|
|
154
161
|
<a href="https://github.com/lchavezcuu">
|
|
155
162
|
<img src="https://avatars.githubusercontent.com/u/22165856?v=4" width="100;" alt="lchavezcuu"/>
|
|
@@ -109,6 +109,13 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
|
|
109
109
|
<sub><b>Ivan F. Martinez</b></sub>
|
|
110
110
|
</a>
|
|
111
111
|
</td>
|
|
112
|
+
<td align="center">
|
|
113
|
+
<a href="https://github.com/eyadkobatte">
|
|
114
|
+
<img src="https://avatars.githubusercontent.com/u/16541074?v=4" width="100;" alt="eyadkobatte"/>
|
|
115
|
+
<br />
|
|
116
|
+
<sub><b>Eyad Kobatte</b></sub>
|
|
117
|
+
</a>
|
|
118
|
+
</td>
|
|
112
119
|
<td align="center">
|
|
113
120
|
<a href="https://github.com/AzonInc">
|
|
114
121
|
<img src="https://avatars.githubusercontent.com/u/11911587?v=4" width="100;" alt="AzonInc"/>
|
|
@@ -116,6 +123,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
|
|
116
123
|
<sub><b>Flo</b></sub>
|
|
117
124
|
</a>
|
|
118
125
|
</td>
|
|
126
|
+
</tr>
|
|
127
|
+
<tr>
|
|
119
128
|
<td align="center">
|
|
120
129
|
<a href="https://github.com/francescolf">
|
|
121
130
|
<img src="https://avatars.githubusercontent.com/u/14892143?v=4" width="100;" alt="francescolf"/>
|
|
@@ -123,8 +132,6 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
|
|
123
132
|
<sub><b>Francesco Lo Faro</b></sub>
|
|
124
133
|
</a>
|
|
125
134
|
</td>
|
|
126
|
-
</tr>
|
|
127
|
-
<tr>
|
|
128
135
|
<td align="center">
|
|
129
136
|
<a href="https://github.com/lchavezcuu">
|
|
130
137
|
<img src="https://avatars.githubusercontent.com/u/22165856?v=4" width="100;" alt="lchavezcuu"/>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "aioamazondevices"
|
|
3
|
-
version = "
|
|
3
|
+
version = "10.0.0"
|
|
4
4
|
requires-python = ">=3.12"
|
|
5
5
|
description = "Python library to control Amazon devices"
|
|
6
6
|
authors = [
|
|
@@ -34,9 +34,9 @@ dependencies = [
|
|
|
34
34
|
"Changelog" = "https://github.com/chemelli74/aioamazondevices/blob/main/CHANGELOG.md"
|
|
35
35
|
|
|
36
36
|
[tool.poetry.group.dev.dependencies]
|
|
37
|
-
pytest = "^
|
|
37
|
+
pytest = "^9.0"
|
|
38
38
|
pytest-cov = ">=5,<8"
|
|
39
|
-
types-python-dateutil = "^2.9.0.
|
|
39
|
+
types-python-dateutil = "^2.9.0.20251115"
|
|
40
40
|
|
|
41
41
|
[tool.semantic_release]
|
|
42
42
|
version_toml = ["pyproject.toml:project.version"]
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
"""Support for Amazon devices."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Coroutine
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
5
|
+
from http import HTTPMethod
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from aiohttp import ClientSession
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .const.devices import (
|
|
12
|
+
DEVICE_TO_IGNORE,
|
|
13
|
+
DEVICE_TYPE_TO_MODEL,
|
|
14
|
+
SPEAKER_GROUP_FAMILY,
|
|
15
|
+
)
|
|
16
|
+
from .const.http import (
|
|
17
|
+
ARRAY_WRAPPER,
|
|
18
|
+
DEFAULT_SITE,
|
|
19
|
+
URI_DEVICES,
|
|
20
|
+
URI_DND,
|
|
21
|
+
URI_NEXUS_GRAPHQL,
|
|
22
|
+
)
|
|
23
|
+
from .const.metadata import SENSORS
|
|
24
|
+
from .const.queries import QUERY_DEVICE_DATA, QUERY_SENSOR_STATE
|
|
25
|
+
from .const.schedules import (
|
|
26
|
+
NOTIFICATION_ALARM,
|
|
27
|
+
NOTIFICATION_REMINDER,
|
|
28
|
+
NOTIFICATION_TIMER,
|
|
29
|
+
)
|
|
30
|
+
from .exceptions import (
|
|
31
|
+
CannotRetrieveData,
|
|
32
|
+
)
|
|
33
|
+
from .http_wrapper import AmazonHttpWrapper, AmazonSessionStateData
|
|
34
|
+
from .implementation.notification import AmazonNotificationHandler
|
|
35
|
+
from .implementation.sequence import AmazonSequenceHandler
|
|
36
|
+
from .login import AmazonLogin
|
|
37
|
+
from .structures import (
|
|
38
|
+
AmazonDevice,
|
|
39
|
+
AmazonDeviceSensor,
|
|
40
|
+
AmazonMusicSource,
|
|
41
|
+
AmazonSequenceType,
|
|
42
|
+
)
|
|
43
|
+
from .utils import _LOGGER
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AmazonEchoApi:
|
|
47
|
+
"""Queries Amazon for Echo devices."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
client_session: ClientSession,
|
|
52
|
+
login_email: str,
|
|
53
|
+
login_password: str,
|
|
54
|
+
login_data: dict[str, Any] | None = None,
|
|
55
|
+
save_to_file: Callable[[str | dict, str, str], Coroutine[Any, Any, None]]
|
|
56
|
+
| None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Initialize the scanner."""
|
|
59
|
+
_LOGGER.debug("Initialize library v%s", __version__)
|
|
60
|
+
|
|
61
|
+
# Check if there is a previous login, otherwise use default (US)
|
|
62
|
+
site = login_data.get("site", DEFAULT_SITE) if login_data else DEFAULT_SITE
|
|
63
|
+
_LOGGER.debug("Using site: %s", site)
|
|
64
|
+
|
|
65
|
+
self._session_state_data = AmazonSessionStateData(
|
|
66
|
+
site, login_email, login_password, login_data
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
self._http_wrapper = AmazonHttpWrapper(
|
|
70
|
+
client_session,
|
|
71
|
+
self._session_state_data,
|
|
72
|
+
save_to_file,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
self._login = AmazonLogin(
|
|
76
|
+
http_wrapper=self._http_wrapper,
|
|
77
|
+
session_state_data=self._session_state_data,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
self._notification_handler = AmazonNotificationHandler(
|
|
81
|
+
http_wrapper=self._http_wrapper,
|
|
82
|
+
session_state_data=self._session_state_data,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
self._sequence_handler = AmazonSequenceHandler(
|
|
86
|
+
http_wrapper=self._http_wrapper,
|
|
87
|
+
session_state_data=self._session_state_data,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
self._final_devices: dict[str, AmazonDevice] = {}
|
|
91
|
+
self._endpoints: dict[str, str] = {} # endpoint ID to serial number map
|
|
92
|
+
|
|
93
|
+
initial_time = datetime.now(UTC) - timedelta(days=2) # force initial refresh
|
|
94
|
+
self._last_devices_refresh: datetime = initial_time
|
|
95
|
+
self._last_endpoint_refresh: datetime = initial_time
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def domain(self) -> str:
|
|
99
|
+
"""Return current Amazon domain."""
|
|
100
|
+
return self._session_state_data.domain
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def login(self) -> AmazonLogin:
|
|
104
|
+
"""Return login."""
|
|
105
|
+
return self._login
|
|
106
|
+
|
|
107
|
+
async def _get_sensors_states(self) -> dict[str, dict[str, AmazonDeviceSensor]]:
|
|
108
|
+
"""Retrieve devices sensors states."""
|
|
109
|
+
devices_sensors: dict[str, dict[str, AmazonDeviceSensor]] = {}
|
|
110
|
+
|
|
111
|
+
if not self._endpoints:
|
|
112
|
+
return {}
|
|
113
|
+
|
|
114
|
+
endpoint_ids = list(self._endpoints.keys())
|
|
115
|
+
payload = [
|
|
116
|
+
{
|
|
117
|
+
"operationName": "getEndpointState",
|
|
118
|
+
"variables": {
|
|
119
|
+
"endpointIds": endpoint_ids,
|
|
120
|
+
},
|
|
121
|
+
"query": QUERY_SENSOR_STATE,
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
_, raw_resp = await self._http_wrapper.session_request(
|
|
126
|
+
method=HTTPMethod.POST,
|
|
127
|
+
url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_NEXUS_GRAPHQL}",
|
|
128
|
+
input_data=payload,
|
|
129
|
+
json_data=True,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
sensors_state = await self._http_wrapper.response_to_json(raw_resp, "sensors")
|
|
133
|
+
|
|
134
|
+
if await self._format_human_error(sensors_state):
|
|
135
|
+
# Explicit error in returned data
|
|
136
|
+
return {}
|
|
137
|
+
|
|
138
|
+
if (
|
|
139
|
+
not (arr := sensors_state.get(ARRAY_WRAPPER))
|
|
140
|
+
or not (data := arr[0].get("data"))
|
|
141
|
+
or not (endpoints_list := data.get("listEndpoints"))
|
|
142
|
+
or not (endpoints := endpoints_list.get("endpoints"))
|
|
143
|
+
):
|
|
144
|
+
_LOGGER.error("Malformed sensor state data received: %s", sensors_state)
|
|
145
|
+
return {}
|
|
146
|
+
|
|
147
|
+
for endpoint in endpoints:
|
|
148
|
+
serial_number = self._endpoints[endpoint.get("endpointId")]
|
|
149
|
+
|
|
150
|
+
if serial_number in self._final_devices:
|
|
151
|
+
devices_sensors[serial_number] = self._get_device_sensor_state(
|
|
152
|
+
endpoint, serial_number
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return devices_sensors
|
|
156
|
+
|
|
157
|
+
def _get_device_sensor_state(
|
|
158
|
+
self, endpoint: dict[str, Any], serial_number: str
|
|
159
|
+
) -> dict[str, AmazonDeviceSensor]:
|
|
160
|
+
device_sensors: dict[str, AmazonDeviceSensor] = {}
|
|
161
|
+
for feature in endpoint.get("features", {}):
|
|
162
|
+
if (sensor_template := SENSORS.get(feature["name"])) is None:
|
|
163
|
+
# Skip sensors that are not in the predefined list
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
if not (name := sensor_template["name"]):
|
|
167
|
+
raise CannotRetrieveData("Unable to read sensor template")
|
|
168
|
+
|
|
169
|
+
for feature_property in feature.get("properties"):
|
|
170
|
+
if sensor_template["name"] != feature_property.get("name"):
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
value: str | int | float = "n/a"
|
|
174
|
+
scale: str | None = None
|
|
175
|
+
|
|
176
|
+
# "error" can be None, missing, or a dict
|
|
177
|
+
api_error = feature_property.get("error") or {}
|
|
178
|
+
error = bool(api_error)
|
|
179
|
+
error_type = api_error.get("type")
|
|
180
|
+
error_msg = api_error.get("message")
|
|
181
|
+
if not error:
|
|
182
|
+
try:
|
|
183
|
+
value_raw = feature_property[sensor_template["key"]]
|
|
184
|
+
if not value_raw:
|
|
185
|
+
_LOGGER.warning(
|
|
186
|
+
"Sensor %s [device %s] ignored due to empty value",
|
|
187
|
+
name,
|
|
188
|
+
serial_number,
|
|
189
|
+
)
|
|
190
|
+
continue
|
|
191
|
+
scale = (
|
|
192
|
+
value_raw[scale_template]
|
|
193
|
+
if (scale_template := sensor_template["scale"])
|
|
194
|
+
else None
|
|
195
|
+
)
|
|
196
|
+
value = (
|
|
197
|
+
value_raw[subkey_template]
|
|
198
|
+
if (subkey_template := sensor_template["subkey"])
|
|
199
|
+
else value_raw
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
except (KeyError, ValueError) as exc:
|
|
203
|
+
_LOGGER.warning(
|
|
204
|
+
"Sensor %s [device %s] ignored due to errors in feature %s: %s", # noqa: E501
|
|
205
|
+
name,
|
|
206
|
+
serial_number,
|
|
207
|
+
feature_property,
|
|
208
|
+
repr(exc),
|
|
209
|
+
)
|
|
210
|
+
if error:
|
|
211
|
+
_LOGGER.debug(
|
|
212
|
+
"error in sensor %s - %s - %s", name, error_type, error_msg
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if error_type != "NOT_FOUND":
|
|
216
|
+
device_sensors[name] = AmazonDeviceSensor(
|
|
217
|
+
name,
|
|
218
|
+
value,
|
|
219
|
+
error,
|
|
220
|
+
error_type,
|
|
221
|
+
error_msg,
|
|
222
|
+
scale,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return device_sensors
|
|
226
|
+
|
|
227
|
+
async def _get_devices_endpoint_data(self) -> dict[str, dict[str, Any]]:
|
|
228
|
+
"""Get Devices endpoint data."""
|
|
229
|
+
payload = {
|
|
230
|
+
"operationName": "getDevicesBaseData",
|
|
231
|
+
"query": QUERY_DEVICE_DATA,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
_, raw_resp = await self._http_wrapper.session_request(
|
|
235
|
+
method=HTTPMethod.POST,
|
|
236
|
+
url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_NEXUS_GRAPHQL}",
|
|
237
|
+
input_data=payload,
|
|
238
|
+
json_data=True,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
endpoint_data = await self._http_wrapper.response_to_json(raw_resp, "endpoint")
|
|
242
|
+
|
|
243
|
+
if not (data := endpoint_data.get("data")) or not data.get("listEndpoints"):
|
|
244
|
+
await self._format_human_error(endpoint_data)
|
|
245
|
+
return {}
|
|
246
|
+
|
|
247
|
+
endpoints = data["listEndpoints"]
|
|
248
|
+
devices_endpoints: dict[str, dict[str, Any]] = {}
|
|
249
|
+
for endpoint in endpoints.get("endpoints"):
|
|
250
|
+
# save looking up sensor data on apps
|
|
251
|
+
if endpoint.get("alexaEnabledMetadata", {}).get("category") == "APP":
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
if endpoint.get("serialNumber"):
|
|
255
|
+
serial_number = endpoint["serialNumber"]["value"]["text"]
|
|
256
|
+
devices_endpoints[serial_number] = endpoint
|
|
257
|
+
self._endpoints[endpoint["endpointId"]] = serial_number
|
|
258
|
+
|
|
259
|
+
return devices_endpoints
|
|
260
|
+
|
|
261
|
+
async def get_devices_data(
|
|
262
|
+
self,
|
|
263
|
+
) -> dict[str, AmazonDevice]:
|
|
264
|
+
"""Get Amazon devices data."""
|
|
265
|
+
delta_devices = datetime.now(UTC) - self._last_devices_refresh
|
|
266
|
+
if delta_devices >= timedelta(days=1):
|
|
267
|
+
_LOGGER.debug(
|
|
268
|
+
"Refreshing devices data after %s",
|
|
269
|
+
str(timedelta(minutes=round(delta_devices.total_seconds() / 60))),
|
|
270
|
+
)
|
|
271
|
+
# Request base device data
|
|
272
|
+
await self._get_base_devices()
|
|
273
|
+
self._last_devices_refresh = datetime.now(UTC)
|
|
274
|
+
|
|
275
|
+
# Only refresh endpoint data if we have no endpoints yet
|
|
276
|
+
delta_endpoints = datetime.now(UTC) - self._last_endpoint_refresh
|
|
277
|
+
endpoint_refresh_needed = delta_endpoints >= timedelta(days=1)
|
|
278
|
+
endpoints_recently_checked = delta_endpoints < timedelta(minutes=30)
|
|
279
|
+
if (
|
|
280
|
+
not self._endpoints and not endpoints_recently_checked
|
|
281
|
+
) or endpoint_refresh_needed:
|
|
282
|
+
_LOGGER.debug(
|
|
283
|
+
"Refreshing endpoint data after %s",
|
|
284
|
+
str(timedelta(minutes=round(delta_endpoints.total_seconds() / 60))),
|
|
285
|
+
)
|
|
286
|
+
# Set device endpoint data
|
|
287
|
+
await self._set_device_endpoints_data()
|
|
288
|
+
self._last_endpoint_refresh = datetime.now(UTC)
|
|
289
|
+
|
|
290
|
+
await self._get_sensor_data()
|
|
291
|
+
|
|
292
|
+
return self._final_devices
|
|
293
|
+
|
|
294
|
+
async def _get_sensor_data(self) -> None:
|
|
295
|
+
devices_sensors = await self._get_sensors_states()
|
|
296
|
+
dnd_sensors = await self._get_dnd_status()
|
|
297
|
+
notifications = await self._notification_handler.get_notifications()
|
|
298
|
+
for device in self._final_devices.values():
|
|
299
|
+
# Update sensors
|
|
300
|
+
sensors = devices_sensors.get(device.serial_number, {})
|
|
301
|
+
if sensors:
|
|
302
|
+
device.sensors = sensors
|
|
303
|
+
else:
|
|
304
|
+
for device_sensor in device.sensors.values():
|
|
305
|
+
device_sensor.error = True
|
|
306
|
+
if (
|
|
307
|
+
device_dnd := dnd_sensors.get(device.serial_number)
|
|
308
|
+
) and device.device_family != SPEAKER_GROUP_FAMILY:
|
|
309
|
+
device.sensors["dnd"] = device_dnd
|
|
310
|
+
|
|
311
|
+
if notifications is None:
|
|
312
|
+
continue # notifications were not obtained, do not update
|
|
313
|
+
|
|
314
|
+
# Clear old notifications to handle cancelled ones
|
|
315
|
+
device.notifications = {}
|
|
316
|
+
|
|
317
|
+
# Update notifications
|
|
318
|
+
device_notifications = notifications.get(device.serial_number, {})
|
|
319
|
+
|
|
320
|
+
# Add only supported notification types
|
|
321
|
+
for capability, notification_type in [
|
|
322
|
+
("REMINDERS", NOTIFICATION_REMINDER),
|
|
323
|
+
("TIMERS_AND_ALARMS", NOTIFICATION_ALARM),
|
|
324
|
+
("TIMERS_AND_ALARMS", NOTIFICATION_TIMER),
|
|
325
|
+
]:
|
|
326
|
+
if (
|
|
327
|
+
capability in device.capabilities
|
|
328
|
+
and notification_type in device_notifications
|
|
329
|
+
and (
|
|
330
|
+
notification_object := device_notifications.get(
|
|
331
|
+
notification_type
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
):
|
|
335
|
+
device.notifications[notification_type] = notification_object
|
|
336
|
+
|
|
337
|
+
async def _set_device_endpoints_data(self) -> None:
|
|
338
|
+
"""Set device endpoint data."""
|
|
339
|
+
devices_endpoints = await self._get_devices_endpoint_data()
|
|
340
|
+
for serial_number in self._final_devices:
|
|
341
|
+
device_endpoint = devices_endpoints.get(serial_number, {})
|
|
342
|
+
endpoint_device = self._final_devices[serial_number]
|
|
343
|
+
endpoint_device.entity_id = (
|
|
344
|
+
device_endpoint["legacyIdentifiers"]["chrsIdentifier"]["entityId"]
|
|
345
|
+
if device_endpoint
|
|
346
|
+
else None
|
|
347
|
+
)
|
|
348
|
+
endpoint_device.endpoint_id = (
|
|
349
|
+
device_endpoint["endpointId"] if device_endpoint else None
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
async def _get_base_devices(self) -> None:
|
|
353
|
+
_, raw_resp = await self._http_wrapper.session_request(
|
|
354
|
+
method=HTTPMethod.GET,
|
|
355
|
+
url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_DEVICES}",
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
json_data = await self._http_wrapper.response_to_json(raw_resp, "devices")
|
|
359
|
+
|
|
360
|
+
final_devices_list: dict[str, AmazonDevice] = {}
|
|
361
|
+
serial_to_device_type: dict[str, str] = {}
|
|
362
|
+
for device in json_data["devices"]:
|
|
363
|
+
# Remove stale, orphaned and virtual devices
|
|
364
|
+
if not device or (device.get("deviceType") in DEVICE_TO_IGNORE):
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
account_name: str = device["accountName"]
|
|
368
|
+
capabilities: list[str] = device["capabilities"]
|
|
369
|
+
# Skip devices that cannot be used with voice features
|
|
370
|
+
if "MICROPHONE" not in capabilities:
|
|
371
|
+
_LOGGER.debug(
|
|
372
|
+
"Skipping device without microphone capabilities: %s", account_name
|
|
373
|
+
)
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
serial_number: str = device["serialNumber"]
|
|
377
|
+
|
|
378
|
+
final_devices_list[serial_number] = AmazonDevice(
|
|
379
|
+
account_name=account_name,
|
|
380
|
+
capabilities=capabilities,
|
|
381
|
+
device_family=device["deviceFamily"],
|
|
382
|
+
device_type=device["deviceType"],
|
|
383
|
+
device_owner_customer_id=device["deviceOwnerCustomerId"],
|
|
384
|
+
household_device=device["deviceOwnerCustomerId"]
|
|
385
|
+
== self._session_state_data.account_customer_id,
|
|
386
|
+
device_cluster_members=dict.fromkeys(
|
|
387
|
+
device["clusterMembers"] or [serial_number]
|
|
388
|
+
),
|
|
389
|
+
online=device["online"],
|
|
390
|
+
serial_number=serial_number,
|
|
391
|
+
software_version=device["softwareVersion"],
|
|
392
|
+
entity_id=None,
|
|
393
|
+
endpoint_id=None,
|
|
394
|
+
sensors={},
|
|
395
|
+
notifications={},
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
serial_to_device_type[serial_number] = device["deviceType"]
|
|
399
|
+
|
|
400
|
+
# backfill device types for cluster members
|
|
401
|
+
for device in final_devices_list.values():
|
|
402
|
+
for member_serial in device.device_cluster_members:
|
|
403
|
+
device.device_cluster_members[member_serial] = (
|
|
404
|
+
serial_to_device_type.get(member_serial)
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
self._final_devices = final_devices_list
|
|
408
|
+
|
|
409
|
+
def get_model_details(self, device: AmazonDevice) -> dict[str, str | None] | None:
|
|
410
|
+
"""Return model datails."""
|
|
411
|
+
model_details: dict[str, str | None] | None = DEVICE_TYPE_TO_MODEL.get(
|
|
412
|
+
device.device_type
|
|
413
|
+
)
|
|
414
|
+
if not model_details:
|
|
415
|
+
_LOGGER.warning(
|
|
416
|
+
"Unknown device type '%s' for %s: please read https://github.com/chemelli74/aioamazondevices/wiki/Unknown-Device-Types",
|
|
417
|
+
device.device_type,
|
|
418
|
+
device.account_name,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
return model_details
|
|
422
|
+
|
|
423
|
+
async def call_alexa_speak(
|
|
424
|
+
self,
|
|
425
|
+
device: AmazonDevice,
|
|
426
|
+
text_to_speak: str,
|
|
427
|
+
) -> None:
|
|
428
|
+
"""Call Alexa.Speak to send a message."""
|
|
429
|
+
await self._sequence_handler.send_message(
|
|
430
|
+
device, AmazonSequenceType.Speak, text_to_speak
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
async def call_alexa_announcement(
|
|
434
|
+
self,
|
|
435
|
+
device: AmazonDevice,
|
|
436
|
+
text_to_announce: str,
|
|
437
|
+
) -> None:
|
|
438
|
+
"""Call AlexaAnnouncement to send a message."""
|
|
439
|
+
await self._sequence_handler.send_message(
|
|
440
|
+
device, AmazonSequenceType.Announcement, text_to_announce
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
async def call_alexa_sound(
|
|
444
|
+
self,
|
|
445
|
+
device: AmazonDevice,
|
|
446
|
+
sound_name: str,
|
|
447
|
+
) -> None:
|
|
448
|
+
"""Call Alexa.Sound to play sound."""
|
|
449
|
+
await self._sequence_handler.send_message(
|
|
450
|
+
device, AmazonSequenceType.Sound, sound_name
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
async def call_alexa_music(
|
|
454
|
+
self,
|
|
455
|
+
device: AmazonDevice,
|
|
456
|
+
search_phrase: str,
|
|
457
|
+
music_source: AmazonMusicSource,
|
|
458
|
+
) -> None:
|
|
459
|
+
"""Call Alexa.Music.PlaySearchPhrase to play music."""
|
|
460
|
+
await self._sequence_handler.send_message(
|
|
461
|
+
device, AmazonSequenceType.Music, search_phrase, music_source
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
async def call_alexa_text_command(
|
|
465
|
+
self,
|
|
466
|
+
device: AmazonDevice,
|
|
467
|
+
text_command: str,
|
|
468
|
+
) -> None:
|
|
469
|
+
"""Call Alexa.TextCommand to issue command."""
|
|
470
|
+
await self._sequence_handler.send_message(
|
|
471
|
+
device, AmazonSequenceType.TextCommand, text_command
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
async def call_alexa_skill(
|
|
475
|
+
self,
|
|
476
|
+
device: AmazonDevice,
|
|
477
|
+
skill_name: str,
|
|
478
|
+
) -> None:
|
|
479
|
+
"""Call Alexa.LaunchSkill to launch a skill."""
|
|
480
|
+
await self._sequence_handler.send_message(
|
|
481
|
+
device, AmazonSequenceType.LaunchSkill, skill_name
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
async def call_alexa_info_skill(
|
|
485
|
+
self,
|
|
486
|
+
device: AmazonDevice,
|
|
487
|
+
info_skill_name: str,
|
|
488
|
+
) -> None:
|
|
489
|
+
"""Call Info skill. See ALEXA_INFO_SKILLS . const."""
|
|
490
|
+
await self._sequence_handler.send_message(device, info_skill_name, "")
|
|
491
|
+
|
|
492
|
+
async def set_do_not_disturb(self, device: AmazonDevice, state: bool) -> None:
|
|
493
|
+
"""Set do_not_disturb flag."""
|
|
494
|
+
payload = {
|
|
495
|
+
"deviceSerialNumber": device.serial_number,
|
|
496
|
+
"deviceType": device.device_type,
|
|
497
|
+
"enabled": state,
|
|
498
|
+
}
|
|
499
|
+
url = f"https://alexa.amazon.{self._session_state_data.domain}/api/dnd/status"
|
|
500
|
+
await self._http_wrapper.session_request(
|
|
501
|
+
method="PUT",
|
|
502
|
+
url=url,
|
|
503
|
+
input_data=payload,
|
|
504
|
+
json_data=True,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
async def _get_dnd_status(self) -> dict[str, AmazonDeviceSensor]:
|
|
508
|
+
dnd_status: dict[str, AmazonDeviceSensor] = {}
|
|
509
|
+
_, raw_resp = await self._http_wrapper.session_request(
|
|
510
|
+
method=HTTPMethod.GET,
|
|
511
|
+
url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_DND}",
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
dnd_data = await self._http_wrapper.response_to_json(raw_resp, "dnd")
|
|
515
|
+
|
|
516
|
+
for dnd in dnd_data.get("doNotDisturbDeviceStatusList", {}):
|
|
517
|
+
dnd_status[dnd.get("deviceSerialNumber")] = AmazonDeviceSensor(
|
|
518
|
+
name="dnd",
|
|
519
|
+
value=dnd.get("enabled"),
|
|
520
|
+
error=False,
|
|
521
|
+
error_type=None,
|
|
522
|
+
error_msg=None,
|
|
523
|
+
scale=None,
|
|
524
|
+
)
|
|
525
|
+
return dnd_status
|
|
526
|
+
|
|
527
|
+
async def _format_human_error(self, sensors_state: dict) -> bool:
|
|
528
|
+
"""Format human readable error from malformed data."""
|
|
529
|
+
if sensors_state.get(ARRAY_WRAPPER):
|
|
530
|
+
error = sensors_state[ARRAY_WRAPPER][0].get("errors", [])
|
|
531
|
+
else:
|
|
532
|
+
error = sensors_state.get("errors", [])
|
|
533
|
+
|
|
534
|
+
if not error:
|
|
535
|
+
return False
|
|
536
|
+
|
|
537
|
+
msg = error[0].get("message", "Unknown error")
|
|
538
|
+
path = error[0].get("path", "Unknown path")
|
|
539
|
+
_LOGGER.error("Error retrieving devices state: %s for path %s", msg, path)
|
|
540
|
+
return True
|