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.
@@ -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
+ )
@@ -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)
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
+ [![Coverage](https://codecov.io/gh/KapJI/ghome-local-api/graph/badge.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.