ghome-local-api 0.1.0__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.
- ghome_local_api/__init__.py +66 -0
- ghome_local_api/client.py +481 -0
- ghome_local_api/const.py +34 -0
- ghome_local_api/exceptions.py +112 -0
- ghome_local_api/models.py +372 -0
- ghome_local_api/py.typed +0 -0
- ghome_local_api-0.1.0.dist-info/METADATA +167 -0
- ghome_local_api-0.1.0.dist-info/RECORD +10 -0
- ghome_local_api-0.1.0.dist-info/WHEEL +4 -0
- ghome_local_api-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Asynchronous client for the local API of Google Home devices."""
|
|
2
|
+
|
|
3
|
+
from .client import GoogleHomeLocalClient, ResponseObserver, device_url
|
|
4
|
+
from .const import DEFAULT_PORT, DEFAULT_TIMEOUT
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
AnswerTimeoutError,
|
|
7
|
+
CannotConnectError,
|
|
8
|
+
ClientRequestError,
|
|
9
|
+
ConnectionLostError,
|
|
10
|
+
GoogleHomeLocalError,
|
|
11
|
+
InvalidJsonError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
RequestError,
|
|
14
|
+
StatusError,
|
|
15
|
+
TokenRejectedError,
|
|
16
|
+
UnexpectedResponseError,
|
|
17
|
+
)
|
|
18
|
+
from .models import (
|
|
19
|
+
Alarm,
|
|
20
|
+
AlarmsAndTimers,
|
|
21
|
+
AlarmStatus,
|
|
22
|
+
AlarmVolumeReply,
|
|
23
|
+
BluetoothSighting,
|
|
24
|
+
BuildInfo,
|
|
25
|
+
DeleteResult,
|
|
26
|
+
DeviceInfo,
|
|
27
|
+
DoNotDisturbReply,
|
|
28
|
+
JsonObject,
|
|
29
|
+
JsonValue,
|
|
30
|
+
Timer,
|
|
31
|
+
TimerStatus,
|
|
32
|
+
UpdateInfo,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"DEFAULT_PORT",
|
|
37
|
+
"DEFAULT_TIMEOUT",
|
|
38
|
+
"Alarm",
|
|
39
|
+
"AlarmStatus",
|
|
40
|
+
"AlarmVolumeReply",
|
|
41
|
+
"AlarmsAndTimers",
|
|
42
|
+
"AnswerTimeoutError",
|
|
43
|
+
"BluetoothSighting",
|
|
44
|
+
"BuildInfo",
|
|
45
|
+
"CannotConnectError",
|
|
46
|
+
"ClientRequestError",
|
|
47
|
+
"ConnectionLostError",
|
|
48
|
+
"DeleteResult",
|
|
49
|
+
"DeviceInfo",
|
|
50
|
+
"DoNotDisturbReply",
|
|
51
|
+
"GoogleHomeLocalClient",
|
|
52
|
+
"GoogleHomeLocalError",
|
|
53
|
+
"InvalidJsonError",
|
|
54
|
+
"JsonObject",
|
|
55
|
+
"JsonValue",
|
|
56
|
+
"NotFoundError",
|
|
57
|
+
"RequestError",
|
|
58
|
+
"ResponseObserver",
|
|
59
|
+
"StatusError",
|
|
60
|
+
"Timer",
|
|
61
|
+
"TimerStatus",
|
|
62
|
+
"TokenRejectedError",
|
|
63
|
+
"UnexpectedResponseError",
|
|
64
|
+
"UpdateInfo",
|
|
65
|
+
"device_url",
|
|
66
|
+
]
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""The requests to a device's local API, over the caller's aiohttp session."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Sequence
|
|
4
|
+
from http import HTTPStatus
|
|
5
|
+
import ipaddress
|
|
6
|
+
|
|
7
|
+
from aiohttp import (
|
|
8
|
+
ClientConnectionError,
|
|
9
|
+
ClientError,
|
|
10
|
+
ClientPayloadError,
|
|
11
|
+
ClientResponse,
|
|
12
|
+
ClientSession,
|
|
13
|
+
ClientTimeout,
|
|
14
|
+
)
|
|
15
|
+
from aiohttp.client_exceptions import ClientConnectorError, ContentTypeError
|
|
16
|
+
|
|
17
|
+
from .const import (
|
|
18
|
+
API_ENDPOINT_ALARM_DELETE,
|
|
19
|
+
API_ENDPOINT_ALARM_VOLUME,
|
|
20
|
+
API_ENDPOINT_ALARMS,
|
|
21
|
+
API_ENDPOINT_BLUETOOTH_RESULTS,
|
|
22
|
+
API_ENDPOINT_BLUETOOTH_SCAN,
|
|
23
|
+
API_ENDPOINT_BUILD_INFO,
|
|
24
|
+
API_ENDPOINT_DO_NOT_DISTURB,
|
|
25
|
+
API_ENDPOINT_EUREKA_INFO,
|
|
26
|
+
API_ENDPOINT_REBOOT,
|
|
27
|
+
API_ENDPOINT_UPDATE_INFO,
|
|
28
|
+
DEFAULT_PORT,
|
|
29
|
+
DEFAULT_TIMEOUT,
|
|
30
|
+
HEADER_CAST_LOCAL_AUTH,
|
|
31
|
+
HEADER_CONTENT_TYPE,
|
|
32
|
+
JSON_ALARM_VOLUME,
|
|
33
|
+
JSON_NOTIFICATIONS_ENABLED,
|
|
34
|
+
)
|
|
35
|
+
from .exceptions import (
|
|
36
|
+
AnswerTimeoutError,
|
|
37
|
+
CannotConnectError,
|
|
38
|
+
ClientRequestError,
|
|
39
|
+
ConnectionLostError,
|
|
40
|
+
InvalidJsonError,
|
|
41
|
+
NotFoundError,
|
|
42
|
+
RequestError,
|
|
43
|
+
StatusError,
|
|
44
|
+
TokenRejectedError,
|
|
45
|
+
UnexpectedResponseError,
|
|
46
|
+
)
|
|
47
|
+
from .models import (
|
|
48
|
+
AlarmsAndTimers,
|
|
49
|
+
AlarmVolumeReply,
|
|
50
|
+
BluetoothSighting,
|
|
51
|
+
BuildInfo,
|
|
52
|
+
DeleteResult,
|
|
53
|
+
DeviceInfo,
|
|
54
|
+
DoNotDisturbReply,
|
|
55
|
+
JsonObject,
|
|
56
|
+
JsonValue,
|
|
57
|
+
UpdateInfo,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Called with the final response as soon as its status and headers have come, before
|
|
61
|
+
# its body is read. It must not read, release or close the response, or raise.
|
|
62
|
+
type ResponseObserver = Callable[[ClientResponse], None]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def device_url(host: str, path: str, port: int = DEFAULT_PORT) -> str:
|
|
66
|
+
"""Return the URL of a path on the device at this IP address, bracketing an IPv6 one.
|
|
67
|
+
|
|
68
|
+
Raise ValueError if the host isn't an IP address.
|
|
69
|
+
"""
|
|
70
|
+
if isinstance(ipaddress.ip_address(host), ipaddress.IPv6Address):
|
|
71
|
+
host = f"[{host}]"
|
|
72
|
+
return f"https://{host}:{port}/{path}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _request_error(
|
|
76
|
+
error: ClientError | TimeoutError, host: str, path: str, status: int | None
|
|
77
|
+
) -> RequestError:
|
|
78
|
+
"""Return the error for a request that failed with this exception."""
|
|
79
|
+
if isinstance(error, ClientConnectorError):
|
|
80
|
+
return CannotConnectError(host=host, path=path, status=status)
|
|
81
|
+
if isinstance(error, ClientConnectionError | ClientPayloadError):
|
|
82
|
+
return ConnectionLostError(host=host, path=path, status=status)
|
|
83
|
+
if isinstance(error, ClientError):
|
|
84
|
+
return ClientRequestError(host=host, path=path, status=status)
|
|
85
|
+
return AnswerTimeoutError(host=host, path=path, status=status)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _status_error(host: str, path: str, status: int, reason: str | None) -> StatusError:
|
|
89
|
+
"""Return the error for an answer with a status other than 200."""
|
|
90
|
+
if status == HTTPStatus.UNAUTHORIZED:
|
|
91
|
+
return TokenRejectedError(host=host, path=path, status=status, reason=reason)
|
|
92
|
+
if status == HTTPStatus.NOT_FOUND:
|
|
93
|
+
return NotFoundError(host=host, path=path, status=status, reason=reason)
|
|
94
|
+
return StatusError(host=host, path=path, status=status, reason=reason)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class GoogleHomeLocalClient:
|
|
98
|
+
"""A client of the local API of Google Home devices, over the caller's session.
|
|
99
|
+
|
|
100
|
+
It keeps no state but the session and port: each call takes the device's IP
|
|
101
|
+
address, and its local auth token if the request needs one. The session must
|
|
102
|
+
accept the device's self-signed certificate and is never closed here. Nothing is
|
|
103
|
+
retried. Concurrent calls are independent and not ordered.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
__slots__ = ("_port", "_session")
|
|
107
|
+
|
|
108
|
+
def __init__(self, session: ClientSession, *, port: int = DEFAULT_PORT) -> None:
|
|
109
|
+
"""Create a client that sends its requests with this session, to this port."""
|
|
110
|
+
self._session = session
|
|
111
|
+
self._port = port
|
|
112
|
+
|
|
113
|
+
async def _send(
|
|
114
|
+
self,
|
|
115
|
+
host: str,
|
|
116
|
+
path: str,
|
|
117
|
+
*,
|
|
118
|
+
token: str | None,
|
|
119
|
+
method: str = "GET",
|
|
120
|
+
body: JsonObject | None = None,
|
|
121
|
+
timeout: float,
|
|
122
|
+
on_response: ResponseObserver | None,
|
|
123
|
+
read_body: bool = True,
|
|
124
|
+
) -> JsonValue:
|
|
125
|
+
"""Send a request and return the reply of its 200.
|
|
126
|
+
|
|
127
|
+
With a token, the reply of a body that isn't JSON is {}, and one that can't be
|
|
128
|
+
decoded raises InvalidJsonError. Without one, as for info, both are None.
|
|
129
|
+
Without read_body, the 200 is the whole answer and None is returned.
|
|
130
|
+
"""
|
|
131
|
+
url = device_url(host, path, self._port)
|
|
132
|
+
client_timeout = ClientTimeout(total=timeout)
|
|
133
|
+
status: int | None = None
|
|
134
|
+
reply: JsonValue = None
|
|
135
|
+
try:
|
|
136
|
+
async with (
|
|
137
|
+
self._session.get(url, timeout=client_timeout)
|
|
138
|
+
if token is None
|
|
139
|
+
else self._session.request(
|
|
140
|
+
method,
|
|
141
|
+
url,
|
|
142
|
+
json=body,
|
|
143
|
+
headers={
|
|
144
|
+
HEADER_CAST_LOCAL_AUTH: token,
|
|
145
|
+
HEADER_CONTENT_TYPE: "application/json",
|
|
146
|
+
},
|
|
147
|
+
timeout=client_timeout,
|
|
148
|
+
)
|
|
149
|
+
) as response:
|
|
150
|
+
status = response.status
|
|
151
|
+
if on_response is not None:
|
|
152
|
+
on_response(response)
|
|
153
|
+
if status == HTTPStatus.OK and read_body:
|
|
154
|
+
try:
|
|
155
|
+
reply = await response.json()
|
|
156
|
+
except ContentTypeError:
|
|
157
|
+
reply = None if token is None else {}
|
|
158
|
+
# ValueError includes JSONDecodeError and UnicodeDecodeError. Python's
|
|
159
|
+
# json raises a plain one for an integer of more than 4300 digits, and
|
|
160
|
+
# RecursionError for deep nesting. bytes.decode raises LookupError for
|
|
161
|
+
# a charset that names a codec that isn't a text encoding, such as base64
|
|
162
|
+
except (ValueError, RecursionError, LookupError) as error:
|
|
163
|
+
if token is not None:
|
|
164
|
+
raise InvalidJsonError(host=host, path=path) from error
|
|
165
|
+
except (ClientError, TimeoutError) as error:
|
|
166
|
+
raise _request_error(error, host, path, status) from error
|
|
167
|
+
if status != HTTPStatus.OK:
|
|
168
|
+
# Home Assistant's test mock of a response has no reason
|
|
169
|
+
raise _status_error(host, path, status, getattr(response, "reason", None))
|
|
170
|
+
return reply
|
|
171
|
+
|
|
172
|
+
async def _get_info(
|
|
173
|
+
self,
|
|
174
|
+
host: str,
|
|
175
|
+
path: str,
|
|
176
|
+
*,
|
|
177
|
+
timeout: float,
|
|
178
|
+
on_response: ResponseObserver | None,
|
|
179
|
+
) -> JsonObject | None:
|
|
180
|
+
"""Return the info of a 200, or None for a reply that isn't a JSON object."""
|
|
181
|
+
info = await self._send(
|
|
182
|
+
host, path, token=None, timeout=timeout, on_response=on_response
|
|
183
|
+
)
|
|
184
|
+
return info if isinstance(info, dict) else None
|
|
185
|
+
|
|
186
|
+
async def get_device_info(
|
|
187
|
+
self,
|
|
188
|
+
host: str,
|
|
189
|
+
*,
|
|
190
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
191
|
+
on_response: ResponseObserver | None = None,
|
|
192
|
+
) -> DeviceInfo | None:
|
|
193
|
+
"""Return the device's ID, name and whether it has Google Assistant, without a token.
|
|
194
|
+
|
|
195
|
+
Return None for a 200 whose reply isn't a JSON object, and raise StatusError
|
|
196
|
+
for another status.
|
|
197
|
+
"""
|
|
198
|
+
info = await self._get_info(
|
|
199
|
+
host, API_ENDPOINT_EUREKA_INFO, timeout=timeout, on_response=on_response
|
|
200
|
+
)
|
|
201
|
+
return DeviceInfo.from_json(info) if info is not None else None
|
|
202
|
+
|
|
203
|
+
async def get_info_status(
|
|
204
|
+
self,
|
|
205
|
+
host: str,
|
|
206
|
+
*,
|
|
207
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
208
|
+
on_response: ResponseObserver | None = None,
|
|
209
|
+
) -> int:
|
|
210
|
+
"""Return the status of the device's answer when asked for its info, without a token.
|
|
211
|
+
|
|
212
|
+
A 200 counts once its body has come whole, another status as soon as it comes,
|
|
213
|
+
and neither is undone by a failure while the answer is released. Raise a
|
|
214
|
+
RequestError if no status counts: with .status 200 if the body broke off, else
|
|
215
|
+
None. Redirects aren't followed, so their status is returned.
|
|
216
|
+
"""
|
|
217
|
+
path = API_ENDPOINT_EUREKA_INFO
|
|
218
|
+
url = device_url(host, path, self._port)
|
|
219
|
+
observed: int | None = None
|
|
220
|
+
status: int | None = None
|
|
221
|
+
try:
|
|
222
|
+
async with self._session.get(
|
|
223
|
+
url, timeout=ClientTimeout(total=timeout), allow_redirects=False
|
|
224
|
+
) as response:
|
|
225
|
+
observed = response.status
|
|
226
|
+
if on_response is not None:
|
|
227
|
+
on_response(response)
|
|
228
|
+
if observed == HTTPStatus.OK:
|
|
229
|
+
await response.read()
|
|
230
|
+
status = observed
|
|
231
|
+
except (ClientError, TimeoutError) as error:
|
|
232
|
+
if status is None:
|
|
233
|
+
raise _request_error(error, host, path, observed) from error
|
|
234
|
+
return status
|
|
235
|
+
|
|
236
|
+
async def get_build_info(
|
|
237
|
+
self,
|
|
238
|
+
host: str,
|
|
239
|
+
*,
|
|
240
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
241
|
+
on_response: ResponseObserver | None = None,
|
|
242
|
+
) -> BuildInfo | None:
|
|
243
|
+
"""Return the device's firmware versions, without a token.
|
|
244
|
+
|
|
245
|
+
Return None for a 200 whose reply isn't a JSON object, and raise StatusError
|
|
246
|
+
for another status.
|
|
247
|
+
"""
|
|
248
|
+
info = await self._get_info(
|
|
249
|
+
host, API_ENDPOINT_BUILD_INFO, timeout=timeout, on_response=on_response
|
|
250
|
+
)
|
|
251
|
+
return BuildInfo.from_json(info) if info is not None else None
|
|
252
|
+
|
|
253
|
+
async def get_update_info(
|
|
254
|
+
self,
|
|
255
|
+
host: str,
|
|
256
|
+
*,
|
|
257
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
258
|
+
on_response: ResponseObserver | None = None,
|
|
259
|
+
) -> UpdateInfo | None:
|
|
260
|
+
"""Return whether the device has a firmware update waiting, its uptime and Wi-Fi.
|
|
261
|
+
|
|
262
|
+
No token is needed. Return None for a 200 whose reply isn't a JSON object, and
|
|
263
|
+
raise StatusError for another status.
|
|
264
|
+
"""
|
|
265
|
+
info = await self._get_info(
|
|
266
|
+
host, API_ENDPOINT_UPDATE_INFO, timeout=timeout, on_response=on_response
|
|
267
|
+
)
|
|
268
|
+
return UpdateInfo.from_json(info) if info is not None else None
|
|
269
|
+
|
|
270
|
+
async def get_alarms_and_timers(
|
|
271
|
+
self,
|
|
272
|
+
host: str,
|
|
273
|
+
token: str,
|
|
274
|
+
*,
|
|
275
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
276
|
+
on_response: ResponseObserver | None = None,
|
|
277
|
+
) -> AlarmsAndTimers:
|
|
278
|
+
"""Return the device's alarms and timers.
|
|
279
|
+
|
|
280
|
+
Raise UnexpectedResponseError if the reply doesn't have a list of each, or any
|
|
281
|
+
item can't be read.
|
|
282
|
+
"""
|
|
283
|
+
reply = await self._send(
|
|
284
|
+
host,
|
|
285
|
+
API_ENDPOINT_ALARMS,
|
|
286
|
+
token=token,
|
|
287
|
+
timeout=timeout,
|
|
288
|
+
on_response=on_response,
|
|
289
|
+
)
|
|
290
|
+
if (parsed := AlarmsAndTimers.from_json(reply)) is None:
|
|
291
|
+
raise UnexpectedResponseError(
|
|
292
|
+
host=host, path=API_ENDPOINT_ALARMS, body=reply
|
|
293
|
+
)
|
|
294
|
+
return parsed
|
|
295
|
+
|
|
296
|
+
async def delete_alarms_and_timers(
|
|
297
|
+
self,
|
|
298
|
+
host: str,
|
|
299
|
+
token: str,
|
|
300
|
+
ids: Sequence[str],
|
|
301
|
+
*,
|
|
302
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
303
|
+
on_response: ResponseObserver | None = None,
|
|
304
|
+
) -> DeleteResult:
|
|
305
|
+
"""Ask the device to delete the alarms and timers with these IDs in one request.
|
|
306
|
+
|
|
307
|
+
Only a confirmed result says they're deleted. Once the request is sent, a
|
|
308
|
+
failure leaves it unknown whether they were.
|
|
309
|
+
"""
|
|
310
|
+
reply = await self._send(
|
|
311
|
+
host,
|
|
312
|
+
API_ENDPOINT_ALARM_DELETE,
|
|
313
|
+
token=token,
|
|
314
|
+
method="POST",
|
|
315
|
+
body={"ids": list(ids)},
|
|
316
|
+
timeout=timeout,
|
|
317
|
+
on_response=on_response,
|
|
318
|
+
)
|
|
319
|
+
return DeleteResult(raw=reply)
|
|
320
|
+
|
|
321
|
+
async def get_alarm_volume(
|
|
322
|
+
self,
|
|
323
|
+
host: str,
|
|
324
|
+
token: str,
|
|
325
|
+
*,
|
|
326
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
327
|
+
on_response: ResponseObserver | None = None,
|
|
328
|
+
) -> AlarmVolumeReply:
|
|
329
|
+
"""Return the reply with the device's alarm volume, whatever JSON it is."""
|
|
330
|
+
reply = await self._send(
|
|
331
|
+
host,
|
|
332
|
+
API_ENDPOINT_ALARM_VOLUME,
|
|
333
|
+
token=token,
|
|
334
|
+
method="POST",
|
|
335
|
+
timeout=timeout,
|
|
336
|
+
on_response=on_response,
|
|
337
|
+
)
|
|
338
|
+
return AlarmVolumeReply(raw=reply)
|
|
339
|
+
|
|
340
|
+
async def set_alarm_volume(
|
|
341
|
+
self,
|
|
342
|
+
host: str,
|
|
343
|
+
token: str,
|
|
344
|
+
level: float,
|
|
345
|
+
*,
|
|
346
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
347
|
+
on_response: ResponseObserver | None = None,
|
|
348
|
+
) -> AlarmVolumeReply:
|
|
349
|
+
"""Set the device's alarm volume, a fraction such as 0.7, and return its reply."""
|
|
350
|
+
reply = await self._send(
|
|
351
|
+
host,
|
|
352
|
+
API_ENDPOINT_ALARM_VOLUME,
|
|
353
|
+
token=token,
|
|
354
|
+
method="POST",
|
|
355
|
+
body={JSON_ALARM_VOLUME: level},
|
|
356
|
+
timeout=timeout,
|
|
357
|
+
on_response=on_response,
|
|
358
|
+
)
|
|
359
|
+
return AlarmVolumeReply(raw=reply)
|
|
360
|
+
|
|
361
|
+
async def get_do_not_disturb(
|
|
362
|
+
self,
|
|
363
|
+
host: str,
|
|
364
|
+
token: str,
|
|
365
|
+
*,
|
|
366
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
367
|
+
on_response: ResponseObserver | None = None,
|
|
368
|
+
) -> DoNotDisturbReply:
|
|
369
|
+
"""Return the reply with the device's Do Not Disturb setting, whatever JSON it is."""
|
|
370
|
+
reply = await self._send(
|
|
371
|
+
host,
|
|
372
|
+
API_ENDPOINT_DO_NOT_DISTURB,
|
|
373
|
+
token=token,
|
|
374
|
+
method="POST",
|
|
375
|
+
timeout=timeout,
|
|
376
|
+
on_response=on_response,
|
|
377
|
+
)
|
|
378
|
+
return DoNotDisturbReply(raw=reply)
|
|
379
|
+
|
|
380
|
+
async def set_do_not_disturb(
|
|
381
|
+
self,
|
|
382
|
+
host: str,
|
|
383
|
+
token: str,
|
|
384
|
+
enabled: bool,
|
|
385
|
+
*,
|
|
386
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
387
|
+
on_response: ResponseObserver | None = None,
|
|
388
|
+
) -> DoNotDisturbReply:
|
|
389
|
+
"""Turn Do Not Disturb on or off, and return the device's reply.
|
|
390
|
+
|
|
391
|
+
The device is sent whether notifications are on, the opposite.
|
|
392
|
+
"""
|
|
393
|
+
reply = await self._send(
|
|
394
|
+
host,
|
|
395
|
+
API_ENDPOINT_DO_NOT_DISTURB,
|
|
396
|
+
token=token,
|
|
397
|
+
method="POST",
|
|
398
|
+
body={JSON_NOTIFICATIONS_ENABLED: not enabled},
|
|
399
|
+
timeout=timeout,
|
|
400
|
+
on_response=on_response,
|
|
401
|
+
)
|
|
402
|
+
return DoNotDisturbReply(raw=reply)
|
|
403
|
+
|
|
404
|
+
async def reboot(
|
|
405
|
+
self,
|
|
406
|
+
host: str,
|
|
407
|
+
token: str,
|
|
408
|
+
*,
|
|
409
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
410
|
+
on_response: ResponseObserver | None = None,
|
|
411
|
+
) -> None:
|
|
412
|
+
"""Ask the device to reboot.
|
|
413
|
+
|
|
414
|
+
Its 200 is the whole answer: the rest isn't read, as the device may go down
|
|
415
|
+
while sending it. A device answers so even if it doesn't support rebooting.
|
|
416
|
+
"""
|
|
417
|
+
await self._send(
|
|
418
|
+
host,
|
|
419
|
+
API_ENDPOINT_REBOOT,
|
|
420
|
+
token=token,
|
|
421
|
+
method="POST",
|
|
422
|
+
# "now" means reboot; "fdr", a factory reset, isn't offered
|
|
423
|
+
body={"params": "now"},
|
|
424
|
+
timeout=timeout,
|
|
425
|
+
on_response=on_response,
|
|
426
|
+
read_body=False,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
async def start_bluetooth_scan(
|
|
430
|
+
self,
|
|
431
|
+
host: str,
|
|
432
|
+
token: str,
|
|
433
|
+
*,
|
|
434
|
+
duration: int,
|
|
435
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
436
|
+
on_response: ResponseObserver | None = None,
|
|
437
|
+
) -> None:
|
|
438
|
+
"""Start a Bluetooth scan that clears the results of the previous one.
|
|
439
|
+
|
|
440
|
+
The device scans for up to duration seconds, then stops on its own.
|
|
441
|
+
"""
|
|
442
|
+
await self._send(
|
|
443
|
+
host,
|
|
444
|
+
API_ENDPOINT_BLUETOOTH_SCAN,
|
|
445
|
+
token=token,
|
|
446
|
+
method="POST",
|
|
447
|
+
body={"enable": True, "clear_results": True, "timeout": duration},
|
|
448
|
+
timeout=timeout,
|
|
449
|
+
on_response=on_response,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
async def get_bluetooth_scan_results(
|
|
453
|
+
self,
|
|
454
|
+
host: str,
|
|
455
|
+
token: str,
|
|
456
|
+
*,
|
|
457
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
458
|
+
on_response: ResponseObserver | None = None,
|
|
459
|
+
) -> tuple[BluetoothSighting, ...]:
|
|
460
|
+
"""Return the Bluetooth devices the device heard since its scan started.
|
|
461
|
+
|
|
462
|
+
Rows that can't be read are left out, so a reply of only such rows looks like
|
|
463
|
+
a scan that heard nothing. Raise UnexpectedResponseError if the reply isn't a
|
|
464
|
+
list.
|
|
465
|
+
"""
|
|
466
|
+
reply = await self._send(
|
|
467
|
+
host,
|
|
468
|
+
API_ENDPOINT_BLUETOOTH_RESULTS,
|
|
469
|
+
token=token,
|
|
470
|
+
timeout=timeout,
|
|
471
|
+
on_response=on_response,
|
|
472
|
+
)
|
|
473
|
+
if not isinstance(reply, list):
|
|
474
|
+
raise UnexpectedResponseError(
|
|
475
|
+
host=host, path=API_ENDPOINT_BLUETOOTH_RESULTS, body=reply
|
|
476
|
+
)
|
|
477
|
+
return tuple(
|
|
478
|
+
sighting
|
|
479
|
+
for row in reply
|
|
480
|
+
if (sighting := BluetoothSighting.from_json(row)) is not None
|
|
481
|
+
)
|
ghome_local_api/const.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""The device's port, paths, header names and JSON keys, and the default timeout."""
|
|
2
|
+
|
|
3
|
+
from typing import Final
|
|
4
|
+
|
|
5
|
+
DEFAULT_PORT: Final = 8443
|
|
6
|
+
# How long a device has to answer a request, in seconds. A busy device can take
|
|
7
|
+
# more than 2 seconds.
|
|
8
|
+
DEFAULT_TIMEOUT: Final = 5.0
|
|
9
|
+
|
|
10
|
+
# Paths that need the device's local auth token
|
|
11
|
+
API_ENDPOINT_ALARMS: Final = "setup/assistant/alarms"
|
|
12
|
+
API_ENDPOINT_ALARM_DELETE: Final = "setup/assistant/alarms/delete"
|
|
13
|
+
API_ENDPOINT_ALARM_VOLUME: Final = "setup/assistant/alarms/volume"
|
|
14
|
+
API_ENDPOINT_REBOOT: Final = "setup/reboot"
|
|
15
|
+
API_ENDPOINT_DO_NOT_DISTURB: Final = "setup/assistant/notifications"
|
|
16
|
+
API_ENDPOINT_BLUETOOTH_SCAN: Final = "setup/bluetooth/scan"
|
|
17
|
+
API_ENDPOINT_BLUETOOTH_RESULTS: Final = "setup/bluetooth/scan_results"
|
|
18
|
+
|
|
19
|
+
# Paths that answer without a token. The first identifies a device and shows
|
|
20
|
+
# whether it has Google Assistant.
|
|
21
|
+
API_ENDPOINT_EUREKA_INFO: Final = "setup/eureka_info?params=name,device_info"
|
|
22
|
+
# The device's firmware versions. A display's system version is only here.
|
|
23
|
+
API_ENDPOINT_BUILD_INFO: Final = "setup/eureka_info?params=build_info"
|
|
24
|
+
# Without params, the info has whether the device has a firmware update waiting,
|
|
25
|
+
# which no params give, and its Wi-Fi network and signal strength.
|
|
26
|
+
API_ENDPOINT_UPDATE_INFO: Final = "setup/eureka_info"
|
|
27
|
+
|
|
28
|
+
HEADER_CAST_LOCAL_AUTH: Final = "cast-local-authorization-token"
|
|
29
|
+
HEADER_CONTENT_TYPE: Final = "content-type"
|
|
30
|
+
|
|
31
|
+
JSON_ALARM: Final = "alarm"
|
|
32
|
+
JSON_TIMER: Final = "timer"
|
|
33
|
+
JSON_NOTIFICATIONS_ENABLED: Final = "notifications_enabled"
|
|
34
|
+
JSON_ALARM_VOLUME: Final = "volume"
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The errors of a request to a device.
|
|
2
|
+
|
|
3
|
+
Their messages name the device's host, the path and any status, never its token, a
|
|
4
|
+
reply or the reason text a device sends with its status. The aiohttp exception chained
|
|
5
|
+
as __cause__ isn't sanitised: it can hold the request headers, with the token.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .models import JsonValue
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GoogleHomeLocalError(Exception):
|
|
12
|
+
"""A request to a device got no answer, or not the one expected."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, message: str, *, host: str, path: str) -> None:
|
|
15
|
+
"""Create the error of a request to this host and path."""
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.host = host
|
|
18
|
+
self.path = path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RequestError(GoogleHomeLocalError):
|
|
22
|
+
"""No usable answer came: __cause__ is the aiohttp exception or TimeoutError.
|
|
23
|
+
|
|
24
|
+
The status is that of an answer that came before the failure, such as a 200 whose
|
|
25
|
+
body broke off, else None.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, *, host: str, path: str, status: int | None) -> None:
|
|
29
|
+
"""Create the error of a request whose answer, if any, had this status."""
|
|
30
|
+
super().__init__(
|
|
31
|
+
f"{host} gave no usable answer to {path}"
|
|
32
|
+
+ (f" after answering {status}" if status is not None else ""),
|
|
33
|
+
host=host,
|
|
34
|
+
path=path,
|
|
35
|
+
)
|
|
36
|
+
self.status = status
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CannotConnectError(RequestError):
|
|
40
|
+
"""The connection to the device failed, for example because it is offline."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ConnectionLostError(RequestError):
|
|
44
|
+
"""The connection closed or the answer broke off; the device may have acted."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AnswerTimeoutError(RequestError):
|
|
48
|
+
"""The device didn't answer in time; it may have acted."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ClientRequestError(RequestError):
|
|
52
|
+
"""aiohttp failed the request otherwise, such as after too many redirects."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class StatusError(GoogleHomeLocalError):
|
|
56
|
+
"""The device answered with a status other than 200.
|
|
57
|
+
|
|
58
|
+
The reason is the text the device sent with the status, such as "Not Found". It
|
|
59
|
+
comes from the device, so it is left out of the message; treat it as untrusted.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self, *, host: str, path: str, status: int, reason: str | None
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Create the error of an answer with this status and reason."""
|
|
66
|
+
super().__init__(
|
|
67
|
+
f"{host} answered {path} with HTTP {status}", host=host, path=path
|
|
68
|
+
)
|
|
69
|
+
self.status = status
|
|
70
|
+
self.reason = reason
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TokenRejectedError(StatusError):
|
|
74
|
+
"""The device answered 401: it rejected the token."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class NotFoundError(StatusError):
|
|
78
|
+
"""The device answered 404, for example while it is starting or if it lacks the endpoint."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class InvalidJsonError(GoogleHomeLocalError):
|
|
82
|
+
"""The device answered 200 with a body that says it's JSON but can't be decoded.
|
|
83
|
+
|
|
84
|
+
__cause__ is the decoder's error: a ValueError (such as JSONDecodeError or
|
|
85
|
+
UnicodeDecodeError), a RecursionError, or a LookupError for a charset that names a
|
|
86
|
+
codec that isn't a text encoding.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, *, host: str, path: str) -> None:
|
|
90
|
+
"""Create the error of a reply that isn't valid JSON."""
|
|
91
|
+
super().__init__(
|
|
92
|
+
f"{host} answered {path} with JSON that can't be decoded",
|
|
93
|
+
host=host,
|
|
94
|
+
path=path,
|
|
95
|
+
)
|
|
96
|
+
self.status = 200
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class UnexpectedResponseError(GoogleHomeLocalError):
|
|
100
|
+
"""The device answered 200 with JSON that isn't in the expected form.
|
|
101
|
+
|
|
102
|
+
The body is the decoded reply. It holds device data, so don't log it as it is.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, *, host: str, path: str, body: JsonValue) -> None:
|
|
106
|
+
"""Create the error of a reply that isn't in the expected form."""
|
|
107
|
+
super().__init__(
|
|
108
|
+
f"{host} answered {path} with a reply that isn't in the expected form",
|
|
109
|
+
host=host,
|
|
110
|
+
path=path,
|
|
111
|
+
)
|
|
112
|
+
self.body = body
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Typed replies of the local API, read with the rules ha-google-home applies to them."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
import math
|
|
7
|
+
from typing import Final, Self, override
|
|
8
|
+
|
|
9
|
+
from .const import JSON_ALARM, JSON_ALARM_VOLUME, JSON_NOTIFICATIONS_ENABLED, JSON_TIMER
|
|
10
|
+
|
|
11
|
+
type JsonValue = (
|
|
12
|
+
None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue]
|
|
13
|
+
)
|
|
14
|
+
type JsonObject = Mapping[str, JsonValue]
|
|
15
|
+
|
|
16
|
+
# The fields of a device's build info that are kept: its versions and release channel
|
|
17
|
+
BUILD_INFO_FIELDS: Final = (
|
|
18
|
+
"cast_build_revision",
|
|
19
|
+
"system_build_number",
|
|
20
|
+
"release_track",
|
|
21
|
+
"preview_channel_state",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _number(value: JsonValue) -> int | float | None:
|
|
26
|
+
"""Return a JSON number that is whole or finite, else None.
|
|
27
|
+
|
|
28
|
+
True and false aren't numbers. A whole number isn't turned into a float to check
|
|
29
|
+
it, so one too large to convert is left for its conversion to reject.
|
|
30
|
+
"""
|
|
31
|
+
if isinstance(value, float):
|
|
32
|
+
return value if math.isfinite(value) else None
|
|
33
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _whole(value: JsonValue) -> int | None:
|
|
37
|
+
"""Return a JSON whole number, but not true or false, else None."""
|
|
38
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_all[T](
|
|
42
|
+
items: list[JsonValue], parse: Callable[[JsonValue], T | None]
|
|
43
|
+
) -> tuple[T, ...] | None:
|
|
44
|
+
"""Return every item parsed, or None if any can't be."""
|
|
45
|
+
parsed = []
|
|
46
|
+
for item in items:
|
|
47
|
+
if (value := parse(item)) is None:
|
|
48
|
+
return None
|
|
49
|
+
parsed.append(value)
|
|
50
|
+
return tuple(parsed)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AlarmStatus(Enum):
|
|
54
|
+
"""The status of an alarm."""
|
|
55
|
+
|
|
56
|
+
NONE = 0
|
|
57
|
+
SET = 1
|
|
58
|
+
RINGING = 2
|
|
59
|
+
SNOOZED = 3
|
|
60
|
+
INACTIVE = 4
|
|
61
|
+
MISSED = 5
|
|
62
|
+
# A status this package doesn't know, such as one that newer firmware adds
|
|
63
|
+
UNKNOWN = -1
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
@override
|
|
67
|
+
def _missing_(cls, value: object) -> Self:
|
|
68
|
+
"""Return UNKNOWN for a status this package doesn't know."""
|
|
69
|
+
return cls.UNKNOWN
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class TimerStatus(Enum):
|
|
73
|
+
"""The status of a timer."""
|
|
74
|
+
|
|
75
|
+
NONE = 0
|
|
76
|
+
SET = 1
|
|
77
|
+
PAUSED = 2
|
|
78
|
+
RINGING = 3
|
|
79
|
+
# A status this package doesn't know, such as one that newer firmware adds
|
|
80
|
+
UNKNOWN = -1
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
@override
|
|
84
|
+
def _missing_(cls, value: object) -> Self:
|
|
85
|
+
"""Return UNKNOWN for a status this package doesn't know."""
|
|
86
|
+
return cls.UNKNOWN
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
90
|
+
class Alarm:
|
|
91
|
+
"""An alarm, as the device sent it."""
|
|
92
|
+
|
|
93
|
+
id: str
|
|
94
|
+
# In milliseconds since the epoch
|
|
95
|
+
fire_time: int | float
|
|
96
|
+
status: AlarmStatus
|
|
97
|
+
label: JsonValue
|
|
98
|
+
recurrence: JsonValue
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_json(cls, item: JsonValue) -> Self | None:
|
|
102
|
+
"""Return the alarm, or None if it lacks its ID, a whole-number status or a fire time.
|
|
103
|
+
|
|
104
|
+
A missing label or recurrence is None.
|
|
105
|
+
"""
|
|
106
|
+
if not isinstance(item, dict):
|
|
107
|
+
return None
|
|
108
|
+
alarm_id, status = item.get("id"), _whole(item.get("status"))
|
|
109
|
+
fire_time = _number(item.get("fire_time"))
|
|
110
|
+
if not isinstance(alarm_id, str) or status is None or fire_time is None:
|
|
111
|
+
return None
|
|
112
|
+
return cls(
|
|
113
|
+
id=alarm_id,
|
|
114
|
+
fire_time=fire_time,
|
|
115
|
+
status=AlarmStatus(status),
|
|
116
|
+
label=item.get("label"),
|
|
117
|
+
recurrence=item.get("recurrence"),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
122
|
+
class Timer:
|
|
123
|
+
"""A timer, as the device sent it."""
|
|
124
|
+
|
|
125
|
+
id: str
|
|
126
|
+
# In milliseconds
|
|
127
|
+
original_duration: int | float
|
|
128
|
+
# In milliseconds since the epoch; a paused timer may have none
|
|
129
|
+
fire_time: int | float | None
|
|
130
|
+
# In milliseconds
|
|
131
|
+
remaining_duration: int | float | None
|
|
132
|
+
status: TimerStatus
|
|
133
|
+
label: JsonValue
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def from_json(cls, item: JsonValue) -> Self | None:
|
|
137
|
+
"""Return the timer, or None if it lacks its ID, a whole-number status or its duration.
|
|
138
|
+
|
|
139
|
+
A fire time or remaining duration may be missing or null, which gives None,
|
|
140
|
+
but not another value that isn't a number.
|
|
141
|
+
"""
|
|
142
|
+
if not isinstance(item, dict):
|
|
143
|
+
return None
|
|
144
|
+
timer_id, status = item.get("id"), _whole(item.get("status"))
|
|
145
|
+
duration = _number(item.get("original_duration"))
|
|
146
|
+
if not isinstance(timer_id, str) or status is None or duration is None:
|
|
147
|
+
return None
|
|
148
|
+
fire_time, remaining = item.get("fire_time"), item.get("remaining_duration")
|
|
149
|
+
if any(
|
|
150
|
+
value is not None and _number(value) is None
|
|
151
|
+
for value in (fire_time, remaining)
|
|
152
|
+
):
|
|
153
|
+
return None
|
|
154
|
+
return cls(
|
|
155
|
+
id=timer_id,
|
|
156
|
+
original_duration=duration,
|
|
157
|
+
fire_time=_number(fire_time),
|
|
158
|
+
remaining_duration=_number(remaining),
|
|
159
|
+
status=TimerStatus(status),
|
|
160
|
+
label=item.get("label"),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
165
|
+
class AlarmsAndTimers:
|
|
166
|
+
"""A device's alarms and timers, with the reply they came in."""
|
|
167
|
+
|
|
168
|
+
alarms: tuple[Alarm, ...]
|
|
169
|
+
timers: tuple[Timer, ...]
|
|
170
|
+
raw: JsonValue
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def from_json(cls, body: JsonValue) -> Self | None:
|
|
174
|
+
"""Return the alarms and timers of a reply, or None if it can't be read whole.
|
|
175
|
+
|
|
176
|
+
The reply needs a list of alarms and a list of timers, with every item
|
|
177
|
+
readable, so a reply is never read only in part.
|
|
178
|
+
"""
|
|
179
|
+
if not isinstance(body, dict):
|
|
180
|
+
return None
|
|
181
|
+
alarms, timers = body.get(JSON_ALARM), body.get(JSON_TIMER)
|
|
182
|
+
if not isinstance(alarms, list) or not isinstance(timers, list):
|
|
183
|
+
return None
|
|
184
|
+
parsed_alarms = _parse_all(alarms, Alarm.from_json)
|
|
185
|
+
parsed_timers = _parse_all(timers, Timer.from_json)
|
|
186
|
+
if parsed_alarms is None or parsed_timers is None:
|
|
187
|
+
return None
|
|
188
|
+
return cls(alarms=parsed_alarms, timers=parsed_timers, raw=body)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
192
|
+
class DeleteResult:
|
|
193
|
+
"""The reply to a request to delete alarms or timers."""
|
|
194
|
+
|
|
195
|
+
raw: JsonValue
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def confirmed(self) -> bool:
|
|
199
|
+
"""Return whether the reply confirms the delete: only {"success": true} does."""
|
|
200
|
+
return isinstance(self.raw, dict) and self.raw.get("success") is True
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
204
|
+
class AlarmVolumeReply:
|
|
205
|
+
"""The reply to a request for the alarm volume or to set it; any JSON value."""
|
|
206
|
+
|
|
207
|
+
raw: JsonValue
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def has_volume(self) -> bool:
|
|
211
|
+
"""Return whether the reply is an object with a volume."""
|
|
212
|
+
return isinstance(self.raw, dict) and JSON_ALARM_VOLUME in self.raw
|
|
213
|
+
|
|
214
|
+
def percent(self) -> int:
|
|
215
|
+
"""Return the volume as a whole percentage, from the fraction the device sends.
|
|
216
|
+
|
|
217
|
+
Raise ValueError if the reply has no volume or it isn't a number, such as
|
|
218
|
+
"loud" or NaN, and OverflowError if it is infinite.
|
|
219
|
+
"""
|
|
220
|
+
if not isinstance(self.raw, dict) or JSON_ALARM_VOLUME not in self.raw:
|
|
221
|
+
raise ValueError("The reply has no alarm volume")
|
|
222
|
+
return round(float(str(self.raw[JSON_ALARM_VOLUME])) * 100)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
226
|
+
class DoNotDisturbReply:
|
|
227
|
+
"""The reply to a request for the Do Not Disturb setting or to set it; any JSON value."""
|
|
228
|
+
|
|
229
|
+
raw: JsonValue
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def enabled(self) -> bool | None:
|
|
233
|
+
"""Return whether Do Not Disturb is on, or None if the reply doesn't say.
|
|
234
|
+
|
|
235
|
+
The device sends whether notifications are on, the opposite.
|
|
236
|
+
"""
|
|
237
|
+
if isinstance(self.raw, dict) and JSON_NOTIFICATIONS_ENABLED in self.raw:
|
|
238
|
+
return not bool(self.raw[JSON_NOTIFICATIONS_ENABLED])
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
243
|
+
class DeviceInfo:
|
|
244
|
+
"""What a device reports about itself without a token."""
|
|
245
|
+
|
|
246
|
+
# Google's ID for the device, as sent. Google's device list may use other case.
|
|
247
|
+
cloud_device_id: str | None
|
|
248
|
+
name: JsonValue
|
|
249
|
+
# Whether its list of features leaves out Google Assistant
|
|
250
|
+
lacks_assistant: bool
|
|
251
|
+
|
|
252
|
+
@classmethod
|
|
253
|
+
def from_json(cls, info: JsonObject) -> Self:
|
|
254
|
+
"""Return the device's info from its reply.
|
|
255
|
+
|
|
256
|
+
Google and Nest speakers and displays report assistant_supported as true. A
|
|
257
|
+
Chromecast Audio leaves it out of its list of features, so a missing key
|
|
258
|
+
counts as lacking it, as does false. No list, an empty one, or any other
|
|
259
|
+
value, such as 0, null or "false", says nothing either way.
|
|
260
|
+
"""
|
|
261
|
+
device_info = info.get("device_info")
|
|
262
|
+
cloud_device_id, capabilities = (
|
|
263
|
+
(device_info.get("cloud_device_id"), device_info.get("capabilities"))
|
|
264
|
+
if isinstance(device_info, dict)
|
|
265
|
+
else (None, None)
|
|
266
|
+
)
|
|
267
|
+
return cls(
|
|
268
|
+
cloud_device_id=(
|
|
269
|
+
cloud_device_id
|
|
270
|
+
if isinstance(cloud_device_id, str) and cloud_device_id
|
|
271
|
+
else None
|
|
272
|
+
),
|
|
273
|
+
name=info.get("name"),
|
|
274
|
+
lacks_assistant=(
|
|
275
|
+
isinstance(capabilities, dict)
|
|
276
|
+
and bool(capabilities)
|
|
277
|
+
and capabilities.get("assistant_supported", False) is False
|
|
278
|
+
),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
283
|
+
class BuildInfo:
|
|
284
|
+
"""A device's firmware versions and release channel."""
|
|
285
|
+
|
|
286
|
+
# The BUILD_INFO_FIELDS that it sent, or None if it sent no build info object
|
|
287
|
+
fields: JsonObject | None
|
|
288
|
+
|
|
289
|
+
@classmethod
|
|
290
|
+
def from_json(cls, info: JsonObject) -> Self:
|
|
291
|
+
"""Return the build info from the reply."""
|
|
292
|
+
build_info = info.get("build_info")
|
|
293
|
+
return cls(
|
|
294
|
+
fields=(
|
|
295
|
+
{
|
|
296
|
+
field: build_info[field]
|
|
297
|
+
for field in BUILD_INFO_FIELDS
|
|
298
|
+
if field in build_info
|
|
299
|
+
}
|
|
300
|
+
if isinstance(build_info, dict)
|
|
301
|
+
else None
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def firmware_version(self) -> str | None:
|
|
307
|
+
"""Return the firmware version in the form Google publishes for the device.
|
|
308
|
+
|
|
309
|
+
A display reports its system version, such as 31.20260101.103.1234567, as its
|
|
310
|
+
system build number. A speaker or Chromecast Audio reports only a build there,
|
|
311
|
+
the last part of its Cast version, such as 3.77.512345, which is the one
|
|
312
|
+
Google publishes for it.
|
|
313
|
+
"""
|
|
314
|
+
fields = self.fields or {}
|
|
315
|
+
system = fields.get("system_build_number")
|
|
316
|
+
cast = fields.get("cast_build_revision")
|
|
317
|
+
if isinstance(system, str) and "." in system:
|
|
318
|
+
return system
|
|
319
|
+
if isinstance(cast, str) and cast:
|
|
320
|
+
return cast
|
|
321
|
+
return system if isinstance(system, str) and system else None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
325
|
+
class UpdateInfo:
|
|
326
|
+
"""Whether a device has a firmware update waiting, how long it has been up, and its Wi-Fi."""
|
|
327
|
+
|
|
328
|
+
# In seconds; a finite number that isn't negative
|
|
329
|
+
uptime: int | float | None
|
|
330
|
+
# Only the JSON true or false
|
|
331
|
+
has_update: bool | None
|
|
332
|
+
ssid: str | None
|
|
333
|
+
# In dBm; a JSON number, but not true or false. A Nest Hub sends none.
|
|
334
|
+
signal_level: int | float | None
|
|
335
|
+
|
|
336
|
+
@classmethod
|
|
337
|
+
def from_json(cls, info: JsonObject) -> Self:
|
|
338
|
+
"""Return the update info; a field the reply leaves out or sends otherwise is None."""
|
|
339
|
+
uptime = _number(info.get("uptime"))
|
|
340
|
+
has_update, ssid = info.get("has_update"), info.get("ssid")
|
|
341
|
+
signal = info.get("signal_level")
|
|
342
|
+
return cls(
|
|
343
|
+
uptime=uptime if uptime is not None and uptime >= 0 else None,
|
|
344
|
+
has_update=has_update if isinstance(has_update, bool) else None,
|
|
345
|
+
ssid=ssid if isinstance(ssid, str) and ssid else None,
|
|
346
|
+
signal_level=(
|
|
347
|
+
signal
|
|
348
|
+
if isinstance(signal, int | float) and not isinstance(signal, bool)
|
|
349
|
+
else None
|
|
350
|
+
),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
355
|
+
class BluetoothSighting:
|
|
356
|
+
"""A Bluetooth device that a scan heard."""
|
|
357
|
+
|
|
358
|
+
mac_address: str
|
|
359
|
+
rssi: int
|
|
360
|
+
|
|
361
|
+
@classmethod
|
|
362
|
+
def from_json(cls, row: JsonValue) -> Self | None:
|
|
363
|
+
"""Return the sighting, or None for a row without a string address and an integer RSSI.
|
|
364
|
+
|
|
365
|
+
JSON true passes as an RSSI, as it does in ha-google-home.
|
|
366
|
+
"""
|
|
367
|
+
if not isinstance(row, dict):
|
|
368
|
+
return None
|
|
369
|
+
mac_address, rssi = row.get("mac_address"), row.get("rssi")
|
|
370
|
+
if not isinstance(mac_address, str) or not isinstance(rssi, int):
|
|
371
|
+
return None
|
|
372
|
+
return cls(mac_address=mac_address, rssi=rssi)
|
ghome_local_api/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ghome-local-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Asynchronous client for the local API of Google Home devices
|
|
5
|
+
Project-URL: Homepage, https://github.com/KapJI/ghome-local-api
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/KapJI/ghome-local-api/issues
|
|
7
|
+
Project-URL: Release Notes, https://github.com/KapJI/ghome-local-api/releases
|
|
8
|
+
Author-email: Ruslan Sayfutdinov <ruslan@sayfutdinov.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Software Development
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: aiohttp>=3.14.4
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# ghome-local-api
|
|
24
|
+
|
|
25
|
+
[](https://app.codecov.io/gh/KapJI/ghome-local-api)
|
|
26
|
+
|
|
27
|
+
An asynchronous Python client for the local API of Google Home devices, moved out of [ha-google-home](https://github.com/leikoilja/ha-google-home).
|
|
28
|
+
|
|
29
|
+
It reads and deletes alarms and timers, reads and sets the alarm volume and Do Not Disturb, reboots a device, asks it about itself and its firmware, and runs its Bluetooth scans. It is a thin protocol client: it sends one request per call over your [aiohttp](https://docs.aiohttp.org/) session and returns typed replies. It keeps no state, retries nothing and doesn't log.
|
|
30
|
+
|
|
31
|
+
## Status
|
|
32
|
+
|
|
33
|
+
Versions before 1.0.0 may still change the API.
|
|
34
|
+
|
|
35
|
+
These are unofficial endpoints: Google doesn't document them and can change them in a firmware update. Not every device has every endpoint. One that doesn't usually answers 404, which raises `NotFoundError`, but some refuse the connection instead, which raises `CannotConnectError`. A device that is starting can also answer 404 for a while.
|
|
36
|
+
|
|
37
|
+
## What you need
|
|
38
|
+
|
|
39
|
+
- Python 3.12 or newer, and aiohttp 3.14.4 or newer.
|
|
40
|
+
- The device's IP address. A host name raises `ValueError`.
|
|
41
|
+
- For most calls, the device's current local auth token. This package doesn't get tokens: [glocaltokens](https://github.com/leikoilja/glocaltokens) does. The info calls need no token.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```console
|
|
46
|
+
$ pip install ghome-local-api
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quickstart
|
|
50
|
+
|
|
51
|
+
This prints a device's alarms and timers. It changes nothing on the device.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import asyncio
|
|
55
|
+
from datetime import UTC, datetime
|
|
56
|
+
|
|
57
|
+
import aiohttp
|
|
58
|
+
|
|
59
|
+
from ghome_local_api import GoogleHomeLocalClient
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def main() -> None:
|
|
63
|
+
# The devices use self-signed certificates
|
|
64
|
+
connector = aiohttp.TCPConnector(ssl=False)
|
|
65
|
+
async with aiohttp.ClientSession(connector=connector) as session:
|
|
66
|
+
client = GoogleHomeLocalClient(session)
|
|
67
|
+
reply = await client.get_alarms_and_timers(
|
|
68
|
+
"192.168.1.20", "YOUR_LOCAL_AUTH_TOKEN"
|
|
69
|
+
)
|
|
70
|
+
for alarm in reply.alarms:
|
|
71
|
+
# Times are in milliseconds since the epoch
|
|
72
|
+
fires = datetime.fromtimestamp(alarm.fire_time / 1000, UTC)
|
|
73
|
+
print(alarm.id, alarm.status.name, fires)
|
|
74
|
+
for timer in reply.timers:
|
|
75
|
+
print(timer.id, timer.status.name, timer.remaining_duration)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
asyncio.run(main())
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
You open and close the session; the client never closes it. `ssl=False` accepts the device's self-signed certificate but also turns off certificate checks, so a device on your network can't be told from an impostor that receives the token. Use it only on a network you trust.
|
|
82
|
+
|
|
83
|
+
## Calls
|
|
84
|
+
|
|
85
|
+
Each call takes the device's IP address first, then its token if it needs one. Every call also takes `timeout` (seconds, default 5) and `on_response` (see [Seeing the answer early](#seeing-the-answer-early)).
|
|
86
|
+
|
|
87
|
+
| Call | Returns |
|
|
88
|
+
| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
89
|
+
| `get_alarms_and_timers(host, token)` | `AlarmsAndTimers`: `alarms` and `timers`, with times in milliseconds as the device sent them |
|
|
90
|
+
| `delete_alarms_and_timers(host, token, ids)` | `DeleteResult`: `confirmed` is true only if the device replied `{"success": true}` |
|
|
91
|
+
| `get_alarm_volume(host, token)`, `set_alarm_volume(host, token, level)` | `AlarmVolumeReply`: `has_volume` and `percent()`. `level` is a fraction, such as `0.7` |
|
|
92
|
+
| `get_do_not_disturb(host, token)`, `set_do_not_disturb(host, token, enabled)` | `DoNotDisturbReply`: `enabled`, or `None` if the reply doesn't say |
|
|
93
|
+
| `reboot(host, token)` | `None` once the device answers 200, which it does even if it can't reboot |
|
|
94
|
+
| `start_bluetooth_scan(host, token, duration=...)` | `None`. The device scans for up to `duration` seconds, clearing the last scan's results |
|
|
95
|
+
| `get_bluetooth_scan_results(host, token)` | a tuple of `BluetoothSighting(mac_address, rssi)`. Rows without a string address and an integer RSSI are left out, so a reply of only such rows looks like a scan that heard nothing |
|
|
96
|
+
| `get_device_info(host)` | `DeviceInfo`: `cloud_device_id`, `name` and `lacks_assistant`, or `None` if the reply isn't a JSON object |
|
|
97
|
+
| `get_build_info(host)` | `BuildInfo`: `fields` and `firmware_version`, or `None` as above |
|
|
98
|
+
| `get_update_info(host)` | `UpdateInfo`: `uptime`, `has_update`, `ssid` and `signal_level`, or `None` as above |
|
|
99
|
+
| `get_info_status(host)` | the HTTP status of the info request, without following redirects. A 200 counts only once its whole body has come; if the body breaks off, it raises a `RequestError` with `status` 200 |
|
|
100
|
+
|
|
101
|
+
An alarm or timer status that this package doesn't know is `UNKNOWN`. An alarms reply without a list of alarms and a list of timers, or with any item that can't be read, raises `UnexpectedResponseError`, so you never get only part of the reply.
|
|
102
|
+
|
|
103
|
+
The settings replies accept any JSON. Their `raw` attribute holds the decoded reply, and their helpers never raise `TypeError` for its shape. `percent()` raises `ValueError` if there is no volume or it isn't a number, and `OverflowError` if it is infinite.
|
|
104
|
+
|
|
105
|
+
## Errors
|
|
106
|
+
|
|
107
|
+
All errors derive from `GoogleHomeLocalError` and have `host` and `path` attributes.
|
|
108
|
+
|
|
109
|
+
| Error | When |
|
|
110
|
+
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
111
|
+
| `CannotConnectError` | the connection failed, for example because the device is offline |
|
|
112
|
+
| `ConnectionLostError` | the connection closed or the answer broke off |
|
|
113
|
+
| `AnswerTimeoutError` | the device didn't answer within `timeout` |
|
|
114
|
+
| `ClientRequestError` | aiohttp failed the request otherwise, for example after too many redirects |
|
|
115
|
+
| `TokenRejectedError` | the device answered 401: get a new token |
|
|
116
|
+
| `NotFoundError` | the device answered 404 |
|
|
117
|
+
| `StatusError` | any other status than 200. It has `status`, and `reason`, the text the device sent with the status, which its message leaves out. The two errors above derive from it |
|
|
118
|
+
| `InvalidJsonError` | a 200 whose JSON can't be decoded. The info calls return `None` instead |
|
|
119
|
+
| `UnexpectedResponseError` | an alarms or Bluetooth results reply in another form. It has the reply as `body` |
|
|
120
|
+
|
|
121
|
+
The first four derive from `RequestError`. Its `__cause__` is aiohttp's exception or `TimeoutError`, and its `status` is the status of an answer that came before the failure, such as a 200 whose body broke off, or `None`.
|
|
122
|
+
|
|
123
|
+
Other exceptions pass through unchanged, including `asyncio.CancelledError`, `ValueError` for a host that isn't an IP address, and what `on_response` raises, unless that is an aiohttp `ClientError` or `TimeoutError`, which is classified like a failure of the request.
|
|
124
|
+
|
|
125
|
+
## Timeouts, cancellation and repeated requests
|
|
126
|
+
|
|
127
|
+
- Each call's `timeout` replaces the session's timeout for that request.
|
|
128
|
+
- Cancelling a call raises `CancelledError` once its response is released. If you wrap a call in your own `asyncio.timeout`, you get `TimeoutError`, not a package error.
|
|
129
|
+
- An action that is cancelled, times out or loses its connection after it was sent may still have been carried out. Check the device's state before you send it again.
|
|
130
|
+
- The package retries nothing. After a lost connection, aiohttp may send a GET again once (`get_alarms_and_timers`, `get_bluetooth_scan_results` and the info calls), but never a POST.
|
|
131
|
+
- Redirects are followed, except by `get_info_status`. A 307 or 308 answer to an action makes aiohttp send the same POST to the new location; a 301, 302 or 303 makes it send a GET there.
|
|
132
|
+
|
|
133
|
+
## Session requirements
|
|
134
|
+
|
|
135
|
+
The client calls `session.request(...)` or `session.get(...)` with only the URL, headers, JSON body, timeout and, for `get_info_status`, `allow_redirects=False`. Your session needs:
|
|
136
|
+
|
|
137
|
+
- a connector that accepts the device's self-signed certificate;
|
|
138
|
+
- `raise_for_status` unset;
|
|
139
|
+
- no middleware that sends requests again.
|
|
140
|
+
|
|
141
|
+
The client reads JSON with `response.json()`, so your session's response class decides how JSON is decoded. With aiohttp's default class, Python's `json` module decodes `NaN`, `Infinity` and `1e999`, so `percent()` raises for them, and a huge integer alarm status is `UNKNOWN`. Home Assistant's session decodes with orjson, which rejects the first three (`InvalidJsonError`) and reads a huge integer as a float, which makes the alarms reply unreadable. Python's `json` module also raises `ValueError` for an integer of more than 4300 digits (Python's default limit) and `RecursionError` for very deeply nested JSON. Before parsing, aiohttp's `ClientResponse.json()` decodes the body as text with the charset from the response's `Content-Type`, so a charset that names a Python codec that isn't a text encoding, such as `charset=base64`, raises `LookupError`. The client treats any `ValueError`, `RecursionError` or `LookupError` from decoding as JSON that can't be decoded, which also covers `json.JSONDecodeError`, `UnicodeDecodeError` and orjson's errors. Decoders that raise something else for a body they can't decode aren't supported.
|
|
142
|
+
|
|
143
|
+
## Seeing the answer early
|
|
144
|
+
|
|
145
|
+
`on_response` is called once per call with aiohttp's response, as soon as the status and headers of the final response have come and before the body is read. It isn't called if no response comes. Use it to act on the status as early as possible. For example, ha-google-home notes at a 200 that the device accepted its token, so cancelling the call while the body is read doesn't undo that. It must not read, release or close the response. What it raises leaves the call, and an aiohttp `ClientError` or `TimeoutError` is classified like a failure of the request.
|
|
146
|
+
|
|
147
|
+
## Sensitive data
|
|
148
|
+
|
|
149
|
+
The client holds no token: you pass it to each call. Error messages name the host, path and status, but not the token, the reply or the device's reason text. Some objects do carry sensitive data:
|
|
150
|
+
|
|
151
|
+
- the aiohttp exception in `__cause__` can hold the request, including the token header;
|
|
152
|
+
- a redirect that aiohttp follows sends the token header to the new location, even on another host: aiohttp removes only standard credential headers, such as `Authorization`, when the host changes;
|
|
153
|
+
- `raw`, `UnexpectedResponseError.body`, `StatusError.reason` and the response passed to `on_response` hold what the device sent, such as alarm labels and device IDs.
|
|
154
|
+
|
|
155
|
+
Don't log or store these objects whole.
|
|
156
|
+
|
|
157
|
+
## Origin
|
|
158
|
+
|
|
159
|
+
The client was moved from ha-google-home at commit [`0e3febad86d6adf777b97b05452d5d841c3edfa8`](https://github.com/leikoilja/ha-google-home/tree/0e3febad86d6adf777b97b05452d5d841c3edfa8): the requests from `custom_components/google_home/api.py`, `device_info.py` and `bluetooth.py`, the reply rules from `models.py`, `api.py`, `device_info.py`, `account.py` and `bluetooth.py`, and the paths and keys from `const.py`.
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT, see [LICENSE](https://github.com/KapJI/ghome-local-api/blob/main/LICENSE). Its `Copyright (c) 2020 Ilja Leiko` notice covers the client code moved from ha-google-home.
|
|
164
|
+
|
|
165
|
+
## Contributing
|
|
166
|
+
|
|
167
|
+
See [CONTRIBUTING.md](https://github.com/KapJI/ghome-local-api/blob/main/CONTRIBUTING.md).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ghome_local_api/__init__.py,sha256=CQ-2HOJx4og_o9CziV1oqyUgiBRrInhqrUi4orLt-y8,1407
|
|
2
|
+
ghome_local_api/client.py,sha256=wgWOGuNdHwdqeC1LC6_iedJC-kk7ZPNMJpypcjkCSC4,16608
|
|
3
|
+
ghome_local_api/const.py,sha256=spbGoZ7B5jJUOxtsi6USAjCIUEDqw_P-bCNzj3s0s6k,1554
|
|
4
|
+
ghome_local_api/exceptions.py,sha256=WBvt88zHQl9shSBgXvcAi2-iKbqGgdx42FwVu10l06U,3865
|
|
5
|
+
ghome_local_api/models.py,sha256=fwlfeNEdseR865d-c6ljAEzAfH7zz7KokdU2K7YvG6Q,12749
|
|
6
|
+
ghome_local_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
ghome_local_api-0.1.0.dist-info/METADATA,sha256=PPR50MpGjonX_v9LeafLAp1MWCl05X5jHCVFKUh3ht0,14863
|
|
8
|
+
ghome_local_api-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
9
|
+
ghome_local_api-0.1.0.dist-info/licenses/LICENSE,sha256=IZwQUm0sT7EkedTxvSsFHucyQSO_neRlmtI1vG5NNp8,1105
|
|
10
|
+
ghome_local_api-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2020 Ilja Leiko
|
|
4
|
+
Copyright (c) 2026 Ruslan Sayfutdinov
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|