HAP-python 5.0.0__py3-none-any.whl → 5.2.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.
- {hap_python-5.0.0.dist-info → hap_python-5.2.0.dist-info}/METADATA +8 -4
- hap_python-5.2.0.dist-info/RECORD +35 -0
- {hap_python-5.0.0.dist-info → hap_python-5.2.0.dist-info}/WHEEL +1 -1
- pyhap/accessory.py +103 -48
- pyhap/accessory_driver.py +300 -137
- pyhap/camera.py +666 -44
- pyhap/camera_talkback.py +219 -0
- pyhap/characteristic.py +87 -36
- pyhap/const.py +1 -1
- pyhap/encoder.py +18 -4
- pyhap/hap_crypto.py +38 -28
- pyhap/hap_event.py +4 -6
- pyhap/hap_handler.py +125 -75
- pyhap/hap_protocol.py +173 -53
- pyhap/hap_server.py +24 -16
- pyhap/hds.py +211 -0
- pyhap/hds_protocol.py +313 -0
- pyhap/hds_recording.py +357 -0
- pyhap/hds_server.py +409 -0
- pyhap/hksv_recording.py +364 -0
- pyhap/hsrp.py +50 -23
- pyhap/iid_manager.py +8 -8
- pyhap/loader.py +17 -16
- pyhap/params.py +8 -1
- pyhap/resources/characteristics.json +141 -2
- pyhap/resources/services.json +51 -0
- pyhap/service.py +27 -19
- pyhap/state.py +14 -11
- pyhap/tlv.py +40 -9
- pyhap/util.py +49 -42
- hap_python-5.0.0.dist-info/RECORD +0 -29
- {hap_python-5.0.0.dist-info → hap_python-5.2.0.dist-info}/licenses/AUTHORS +0 -0
- {hap_python-5.0.0.dist-info → hap_python-5.2.0.dist-info}/licenses/LICENSE +0 -0
- {hap_python-5.0.0.dist-info → hap_python-5.2.0.dist-info}/top_level.txt +0 -0
pyhap/accessory_driver.py
CHANGED
|
@@ -2,18 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
Sending updates to clients
|
|
4
4
|
|
|
5
|
-
The process of sending value changes to clients happens in two parts - on one hand,
|
|
6
|
-
value change is indicated by a Characteristic and, on the other, that change is
|
|
7
|
-
client. To begin, typically, something in the Accessory's run method will do
|
|
5
|
+
The process of sending value changes to clients happens in two parts - on one hand,
|
|
6
|
+
the value change is indicated by a Characteristic and, on the other, that change is
|
|
7
|
+
sent to a client. To begin, typically, something in the Accessory's run method will do
|
|
8
8
|
set_value(foo, notify=True) on one of its contained Characteristic. This in turn will
|
|
9
9
|
create a HAP representation of the change and publish it to the Accessory. This will
|
|
10
10
|
then add some more information and eventually the value change will reach the
|
|
11
11
|
AccessoryDriver (all this happens through the publish() interface). The AccessoryDriver
|
|
12
12
|
will then check if there is a client that subscribed for events from this exact
|
|
13
|
-
Characteristic from this exact Accessory (remember, it could be a Bridge with more
|
|
14
|
-
one Accessory in it). If so, a task is created to send the event to the
|
|
15
|
-
terminates the call chain and concludes the publishing process
|
|
16
|
-
the Characteristic does not block waiting for the actual
|
|
13
|
+
Characteristic from this exact Accessory (remember, it could be a Bridge with more
|
|
14
|
+
than one Accessory in it). If so, a task is created to send the event to the
|
|
15
|
+
subscribers. This terminates the call chain and concludes the publishing process
|
|
16
|
+
from the Characteristic, the Characteristic does not block waiting for the actual
|
|
17
|
+
send to happen.
|
|
17
18
|
"""
|
|
18
19
|
|
|
19
20
|
import asyncio
|
|
@@ -28,13 +29,13 @@ import sys
|
|
|
28
29
|
import tempfile
|
|
29
30
|
import threading
|
|
30
31
|
import time
|
|
31
|
-
from typing import Any
|
|
32
|
+
from typing import Any
|
|
32
33
|
|
|
33
34
|
from zeroconf import ServiceInfo
|
|
34
35
|
from zeroconf.asyncio import AsyncZeroconf
|
|
35
36
|
|
|
36
37
|
from pyhap import util
|
|
37
|
-
from pyhap.accessory import Accessory, get_topic
|
|
38
|
+
from pyhap.accessory import Accessory, Bridge, get_topic
|
|
38
39
|
from pyhap.characteristic import Characteristic, CharacteristicError
|
|
39
40
|
from pyhap.const import (
|
|
40
41
|
HAP_PERMISSION_NOTIFY,
|
|
@@ -72,12 +73,23 @@ LEADING_TRAILING_SPACE_DASH = re.compile(r"^[ -]+|[ -]+$")
|
|
|
72
73
|
DASH_REGEX = re.compile(r"[-]+")
|
|
73
74
|
KEYS_TO_EXCLUDE = {HAP_REPR_IID, HAP_REPR_AID}
|
|
74
75
|
|
|
76
|
+
# Consecutive failed pair-setup proofs (each is a setup-code guess) before
|
|
77
|
+
# pair-setup is locked out, and for how long.
|
|
78
|
+
MAX_FAILED_PAIRING_ATTEMPTS = 5
|
|
79
|
+
PAIRING_LOCKOUT_SECONDS = 60
|
|
75
80
|
|
|
76
|
-
|
|
81
|
+
# Prepared writes are short-lived (HomeKit uses ~10 second TTLs); cap both
|
|
82
|
+
# the TTL and the number of pending writes per client so a paired peer cannot
|
|
83
|
+
# grow ``prepared_writes`` without bound.
|
|
84
|
+
MAX_PREPARED_WRITES_PER_CLIENT = 16
|
|
85
|
+
MAX_PREPARED_WRITE_TTL_SECONDS = 30.0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _wrap_char_setter(char: Any, value: Any, client_addr: Any) -> tuple[int, Any]:
|
|
77
89
|
"""Process an characteristic setter callback trapping and logging all exceptions."""
|
|
78
90
|
try:
|
|
79
91
|
response = char.client_update_value(value, client_addr)
|
|
80
|
-
except Exception:
|
|
92
|
+
except Exception:
|
|
81
93
|
logger.exception(
|
|
82
94
|
"%s: Error while setting characteristic %s to %s",
|
|
83
95
|
client_addr,
|
|
@@ -88,11 +100,11 @@ def _wrap_char_setter(char, value, client_addr):
|
|
|
88
100
|
return HAP_SERVER_STATUS.SUCCESS, response
|
|
89
101
|
|
|
90
102
|
|
|
91
|
-
def _wrap_acc_setter(acc, updates_by_service, client_addr):
|
|
103
|
+
def _wrap_acc_setter(acc: Any, updates_by_service: Any, client_addr: Any) -> Any:
|
|
92
104
|
"""Process an accessory setter callback trapping and logging all exceptions."""
|
|
93
105
|
try:
|
|
94
106
|
acc.setter_callback(updates_by_service)
|
|
95
|
-
except Exception:
|
|
107
|
+
except Exception:
|
|
96
108
|
logger.exception(
|
|
97
109
|
"%s: Error while setting characteristics to %s for the %s accessory",
|
|
98
110
|
updates_by_service,
|
|
@@ -103,7 +115,7 @@ def _wrap_acc_setter(acc, updates_by_service, client_addr):
|
|
|
103
115
|
return HAP_SERVER_STATUS.SUCCESS
|
|
104
116
|
|
|
105
117
|
|
|
106
|
-
def _wrap_service_setter(service, chars, client_addr):
|
|
118
|
+
def _wrap_service_setter(service: Any, chars: Any, client_addr: Any) -> Any:
|
|
107
119
|
"""Process a service setter callback trapping and logging all exceptions."""
|
|
108
120
|
# Ideally this would pass the chars as is without converting
|
|
109
121
|
# them to the display_name, but that would break existing
|
|
@@ -111,7 +123,7 @@ def _wrap_service_setter(service, chars, client_addr):
|
|
|
111
123
|
service_chars = {char.display_name: value for char, value in chars.items()}
|
|
112
124
|
try:
|
|
113
125
|
service.setter_callback(service_chars)
|
|
114
|
-
except Exception:
|
|
126
|
+
except Exception:
|
|
115
127
|
logger.exception(
|
|
116
128
|
"%s: Error while setting characteristics to %s for the %s service",
|
|
117
129
|
service_chars,
|
|
@@ -125,7 +137,9 @@ def _wrap_service_setter(service, chars, client_addr):
|
|
|
125
137
|
class AccessoryMDNSServiceInfo(ServiceInfo):
|
|
126
138
|
"""A mDNS service info representation of an accessory."""
|
|
127
139
|
|
|
128
|
-
def __init__(
|
|
140
|
+
def __init__(
|
|
141
|
+
self, accessory: Any, state: State, zeroconf_server: Any = None
|
|
142
|
+
) -> None:
|
|
129
143
|
self.accessory = accessory
|
|
130
144
|
self.state: State = state
|
|
131
145
|
|
|
@@ -147,14 +161,14 @@ class AccessoryMDNSServiceInfo(ServiceInfo):
|
|
|
147
161
|
parsed_addresses=self.state.addresses,
|
|
148
162
|
)
|
|
149
163
|
|
|
150
|
-
def _valid_name(self):
|
|
164
|
+
def _valid_name(self) -> str:
|
|
151
165
|
return re.sub(
|
|
152
166
|
LEADING_TRAILING_SPACE_DASH,
|
|
153
167
|
"",
|
|
154
168
|
re.sub(VALID_MDNS_REGEX, " ", self.accessory.display_name),
|
|
155
169
|
)
|
|
156
170
|
|
|
157
|
-
def _valid_host_name(self):
|
|
171
|
+
def _valid_host_name(self) -> str:
|
|
158
172
|
return re.sub(
|
|
159
173
|
DASH_REGEX,
|
|
160
174
|
"-",
|
|
@@ -164,13 +178,13 @@ class AccessoryMDNSServiceInfo(ServiceInfo):
|
|
|
164
178
|
.strip("-"),
|
|
165
179
|
)
|
|
166
180
|
|
|
167
|
-
def _setup_hash(self):
|
|
181
|
+
def _setup_hash(self) -> str:
|
|
168
182
|
setup_hash_material = self.state.setup_id + self.state.mac
|
|
169
183
|
temp_hash = hashlib.sha512()
|
|
170
184
|
temp_hash.update(setup_hash_material.encode())
|
|
171
185
|
return base64.b64encode(temp_hash.digest()[:4]).decode()
|
|
172
186
|
|
|
173
|
-
def _get_advert_data(self):
|
|
187
|
+
def _get_advert_data(self) -> dict[str, str]:
|
|
174
188
|
"""Generate advertisement data from the accessory."""
|
|
175
189
|
return {
|
|
176
190
|
"md": self._valid_name(),
|
|
@@ -190,8 +204,7 @@ class AccessoryMDNSServiceInfo(ServiceInfo):
|
|
|
190
204
|
|
|
191
205
|
|
|
192
206
|
class AccessoryDriver:
|
|
193
|
-
"""
|
|
194
|
-
An AccessoryDriver mediates between incoming requests from the HAPServer and
|
|
207
|
+
"""An AccessoryDriver mediates between incoming requests from the HAPServer and
|
|
195
208
|
the Accessory.
|
|
196
209
|
|
|
197
210
|
The driver starts and stops the HAPServer, the mDNS advertisements and responds
|
|
@@ -201,28 +214,28 @@ class AccessoryDriver:
|
|
|
201
214
|
def __init__(
|
|
202
215
|
self,
|
|
203
216
|
*,
|
|
204
|
-
address=None,
|
|
205
|
-
port=51234,
|
|
206
|
-
persist_file="accessory.state",
|
|
207
|
-
pincode=None,
|
|
208
|
-
encoder=None,
|
|
209
|
-
loader=None,
|
|
210
|
-
loop=None,
|
|
211
|
-
mac=None,
|
|
212
|
-
listen_address=None,
|
|
213
|
-
advertised_address=None,
|
|
214
|
-
interface_choice=None,
|
|
215
|
-
async_zeroconf_instance=None,
|
|
216
|
-
zeroconf_server=None,
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
Initialize a new AccessoryDriver object.
|
|
217
|
+
address: Any = None,
|
|
218
|
+
port: int = 51234,
|
|
219
|
+
persist_file: str = "accessory.state",
|
|
220
|
+
pincode: Any = None,
|
|
221
|
+
encoder: Any = None,
|
|
222
|
+
loader: Any = None,
|
|
223
|
+
loop: Any = None,
|
|
224
|
+
mac: Any = None,
|
|
225
|
+
listen_address: Any = None,
|
|
226
|
+
advertised_address: Any = None,
|
|
227
|
+
interface_choice: Any = None,
|
|
228
|
+
async_zeroconf_instance: Any = None,
|
|
229
|
+
zeroconf_server: Any = None,
|
|
230
|
+
advertise: bool = True,
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Initialize a new AccessoryDriver object.
|
|
220
233
|
|
|
221
234
|
:param pincode: The pincode that HAP clients must prove they know in order
|
|
222
235
|
to pair with this `Accessory`. Defaults to None, in which case a random
|
|
223
236
|
pincode is generated. The pincode has the format "xxx-xx-xxx", where x is
|
|
224
237
|
a digit.
|
|
225
|
-
:type pincode:
|
|
238
|
+
:type pincode: bytes
|
|
226
239
|
|
|
227
240
|
:param port: The local port on which the accessory will be accessible.
|
|
228
241
|
In other words, this is the port of the HAPServer.
|
|
@@ -257,13 +270,18 @@ class AccessoryDriver:
|
|
|
257
270
|
:param interface_choice: The zeroconf interfaces to listen on.
|
|
258
271
|
:type InterfacesType: [InterfaceChoice.Default, InterfaceChoice.All]
|
|
259
272
|
|
|
260
|
-
:param async_zeroconf_instance: An AsyncZeroconf instance. When running multiple
|
|
261
|
-
bridges a single zeroconf instance can be shared to avoid
|
|
262
|
-
of processing the same data multiple times.
|
|
273
|
+
:param async_zeroconf_instance: An AsyncZeroconf instance. When running multiple
|
|
274
|
+
accessories or bridges a single zeroconf instance can be shared to avoid
|
|
275
|
+
the overhead of processing the same data multiple times.
|
|
263
276
|
|
|
264
277
|
:param zeroconf_server: The server name that will be used for the zeroconf
|
|
265
278
|
ServiceInfo.
|
|
266
279
|
:type zeroconf_server: str
|
|
280
|
+
|
|
281
|
+
:param advertise: Whether to advertise the accessory via mDNS when
|
|
282
|
+
the driver starts. Pass False to run the HAP server without
|
|
283
|
+
mDNS advertising, e.g. for tests or benchmarks.
|
|
284
|
+
:type advertise: bool
|
|
267
285
|
"""
|
|
268
286
|
if loop is None:
|
|
269
287
|
if sys.platform == "win32":
|
|
@@ -271,11 +289,9 @@ class AccessoryDriver:
|
|
|
271
289
|
else:
|
|
272
290
|
loop = asyncio.new_event_loop()
|
|
273
291
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
self.executor = ThreadPoolExecutor(**executor_opts)
|
|
292
|
+
self.executor = ThreadPoolExecutor(
|
|
293
|
+
max_workers=None, thread_name_prefix="SyncWorker"
|
|
294
|
+
)
|
|
279
295
|
loop.set_default_executor(self.executor)
|
|
280
296
|
self.tid = threading.current_thread()
|
|
281
297
|
else:
|
|
@@ -284,8 +300,9 @@ class AccessoryDriver:
|
|
|
284
300
|
|
|
285
301
|
self.loop = loop
|
|
286
302
|
|
|
287
|
-
self.accessory:
|
|
288
|
-
self.
|
|
303
|
+
self.accessory: Accessory | None = None
|
|
304
|
+
self.advertise = advertise
|
|
305
|
+
self.advertiser: AsyncZeroconf | None = async_zeroconf_instance
|
|
289
306
|
self.zeroconf_server = zeroconf_server
|
|
290
307
|
self.interface_choice = interface_choice
|
|
291
308
|
|
|
@@ -293,13 +310,20 @@ class AccessoryDriver:
|
|
|
293
310
|
self.encoder = encoder or AccessoryEncoder()
|
|
294
311
|
self.topics = {} # topic: set of (address, port) of subscribed clients
|
|
295
312
|
self.loader = loader or Loader()
|
|
296
|
-
self.aio_stop_event = None
|
|
313
|
+
self.aio_stop_event: asyncio.Event | None = None
|
|
297
314
|
self.stop_event = threading.Event()
|
|
298
315
|
|
|
299
316
|
self.safe_mode = False
|
|
300
317
|
|
|
301
|
-
self.mdns_service_info = None
|
|
302
|
-
self.
|
|
318
|
+
self.mdns_service_info: ServiceInfo | None = None
|
|
319
|
+
self._srp_verifier: SrpServer | None = None
|
|
320
|
+
|
|
321
|
+
# Pair-setup rate limiting: each M1 regenerates a 3072-bit SRP
|
|
322
|
+
# verifier and each failed proof is a pincode guess, so excess
|
|
323
|
+
# failures lock pair-setup out for a while (see
|
|
324
|
+
# ``register_failed_pairing_attempt``).
|
|
325
|
+
self._failed_pairing_attempts = 0
|
|
326
|
+
self._pairing_lockout_until = 0.0
|
|
303
327
|
|
|
304
328
|
address = address or util.get_local_address()
|
|
305
329
|
advertised_address = advertised_address or address
|
|
@@ -307,12 +331,16 @@ class AccessoryDriver:
|
|
|
307
331
|
address=advertised_address, mac=mac, pincode=pincode, port=port
|
|
308
332
|
)
|
|
309
333
|
|
|
310
|
-
|
|
311
|
-
network_tuple = (
|
|
334
|
+
self._listen_address: str | list[str] = listen_address or address
|
|
335
|
+
network_tuple = (self._listen_address, self.state.port)
|
|
312
336
|
self.http_server = HAPServer(network_tuple, self)
|
|
313
337
|
self.prepared_writes = {}
|
|
314
338
|
|
|
315
|
-
def
|
|
339
|
+
def listen_address(self) -> str | list[str]:
|
|
340
|
+
"""Return the address, or list of addresses, the HAP server listens on."""
|
|
341
|
+
return self._listen_address
|
|
342
|
+
|
|
343
|
+
def start(self) -> None:
|
|
316
344
|
"""Start the event loop and call `start_service`.
|
|
317
345
|
|
|
318
346
|
Pyhap will be stopped gracefully on a KeyBoardInterrupt.
|
|
@@ -322,11 +350,16 @@ class AccessoryDriver:
|
|
|
322
350
|
if (
|
|
323
351
|
threading.current_thread() is threading.main_thread()
|
|
324
352
|
and os.name != "nt"
|
|
353
|
+
# The child watcher API was removed in Python 3.14; there the
|
|
354
|
+
# default event loop reaps subprocesses without one.
|
|
355
|
+
and hasattr(asyncio, "SafeChildWatcher")
|
|
325
356
|
):
|
|
326
357
|
logger.debug("Setting child watcher")
|
|
327
|
-
watcher = asyncio.SafeChildWatcher()
|
|
358
|
+
watcher = asyncio.SafeChildWatcher()
|
|
328
359
|
watcher.attach_loop(self.loop)
|
|
329
|
-
|
|
360
|
+
# Deprecated in 3.12, removed in 3.14; the hasattr guard
|
|
361
|
+
# above keeps this branch unreachable on 3.14+.
|
|
362
|
+
asyncio.set_child_watcher(watcher) # ty: ignore[deprecated]
|
|
330
363
|
else:
|
|
331
364
|
logger.debug(
|
|
332
365
|
"Not setting a child watcher. Set one if "
|
|
@@ -342,20 +375,19 @@ class AccessoryDriver:
|
|
|
342
375
|
self.loop.close()
|
|
343
376
|
logger.info("Closed the event loop")
|
|
344
377
|
|
|
345
|
-
def start_service(self):
|
|
378
|
+
def start_service(self) -> None:
|
|
346
379
|
"""Start the service."""
|
|
347
380
|
self._validate_start()
|
|
348
381
|
self.add_job(self.async_start)
|
|
349
382
|
|
|
350
|
-
def _validate_start(self):
|
|
383
|
+
def _validate_start(self) -> None:
|
|
351
384
|
"""Validate we can start."""
|
|
352
385
|
if self.accessory is None:
|
|
353
386
|
raise ValueError(
|
|
354
|
-
"You must assign an accessory to the driver, "
|
|
355
|
-
"before you can start it."
|
|
387
|
+
"You must assign an accessory to the driver, before you can start it."
|
|
356
388
|
)
|
|
357
389
|
|
|
358
|
-
async def async_start(self):
|
|
390
|
+
async def async_start(self) -> None:
|
|
359
391
|
"""Starts the accessory.
|
|
360
392
|
|
|
361
393
|
- Call the accessory's run method.
|
|
@@ -368,6 +400,7 @@ class AccessoryDriver:
|
|
|
368
400
|
daemon.
|
|
369
401
|
"""
|
|
370
402
|
self._validate_start()
|
|
403
|
+
assert self.accessory is not None # noqa: S101
|
|
371
404
|
self.aio_stop_event = asyncio.Event()
|
|
372
405
|
|
|
373
406
|
logger.info(
|
|
@@ -388,20 +421,21 @@ class AccessoryDriver:
|
|
|
388
421
|
if self.state.set_accessories_hash(self.accessories_hash):
|
|
389
422
|
self.async_persist()
|
|
390
423
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
self.
|
|
395
|
-
|
|
424
|
+
if self.advertise:
|
|
425
|
+
# Advertise the accessory as a mDNS service.
|
|
426
|
+
logger.debug("Starting mDNS.")
|
|
427
|
+
self.mdns_service_info = AccessoryMDNSServiceInfo(
|
|
428
|
+
self.accessory, self.state, self.zeroconf_server
|
|
429
|
+
)
|
|
396
430
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
431
|
+
if not self.advertiser:
|
|
432
|
+
zc_args = {}
|
|
433
|
+
if self.interface_choice is not None:
|
|
434
|
+
zc_args["interfaces"] = self.interface_choice
|
|
435
|
+
self.advertiser = AsyncZeroconf(**zc_args)
|
|
436
|
+
await self.advertiser.async_register_service(
|
|
437
|
+
self.mdns_service_info, cooperating_responders=True
|
|
438
|
+
)
|
|
405
439
|
|
|
406
440
|
# Print accessory setup message
|
|
407
441
|
if not self.state.paired:
|
|
@@ -414,17 +448,23 @@ class AccessoryDriver:
|
|
|
414
448
|
"AccessoryDriver for %s started successfully", self.accessory.display_name
|
|
415
449
|
)
|
|
416
450
|
|
|
417
|
-
def stop(self):
|
|
451
|
+
def stop(self) -> None:
|
|
418
452
|
"""Method to stop pyhap."""
|
|
419
453
|
self.add_job(self.async_stop)
|
|
420
454
|
|
|
421
|
-
async def async_stop(self):
|
|
455
|
+
async def async_stop(self) -> None:
|
|
422
456
|
"""Stops the AccessoryDriver and shutdown all remaining tasks."""
|
|
423
457
|
self.stop_event.set()
|
|
458
|
+
accessory = self.accessory
|
|
459
|
+
assert accessory is not None # noqa: S101
|
|
460
|
+
assert self.aio_stop_event is not None # noqa: S101
|
|
424
461
|
logger.debug("Stopping HAP server and event sending")
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
462
|
+
if self.advertise:
|
|
463
|
+
assert self.advertiser is not None # noqa: S101
|
|
464
|
+
assert self.mdns_service_info is not None # noqa: S101
|
|
465
|
+
logger.debug("Stopping mDNS advertising for %s", accessory.display_name)
|
|
466
|
+
await self.advertiser.async_unregister_service(self.mdns_service_info)
|
|
467
|
+
await self.advertiser.async_close()
|
|
428
468
|
|
|
429
469
|
self.aio_stop_event.set()
|
|
430
470
|
|
|
@@ -432,15 +472,15 @@ class AccessoryDriver:
|
|
|
432
472
|
|
|
433
473
|
logger.info(
|
|
434
474
|
"Stopping accessory %s on address %s, port %s.",
|
|
435
|
-
|
|
475
|
+
accessory.display_name,
|
|
436
476
|
self.state.addresses,
|
|
437
477
|
self.state.port,
|
|
438
478
|
)
|
|
439
479
|
|
|
440
|
-
await self.async_add_job(
|
|
480
|
+
await self.async_add_job(accessory.stop)
|
|
441
481
|
|
|
442
482
|
logger.debug(
|
|
443
|
-
"AccessoryDriver for %s stopped successfully",
|
|
483
|
+
"AccessoryDriver for %s stopped successfully", accessory.display_name
|
|
444
484
|
)
|
|
445
485
|
|
|
446
486
|
# Executor=None means a loop wasn't passed in
|
|
@@ -451,14 +491,14 @@ class AccessoryDriver:
|
|
|
451
491
|
|
|
452
492
|
logger.debug("Stop completed")
|
|
453
493
|
|
|
454
|
-
def add_job(self, target, *args):
|
|
494
|
+
def add_job(self, target: Any, *args: Any) -> None:
|
|
455
495
|
"""Add job to executor pool."""
|
|
456
496
|
if target is None:
|
|
457
497
|
raise ValueError("Don't call add_job with None.")
|
|
458
498
|
self.loop.call_soon_threadsafe(self.async_add_job, target, *args)
|
|
459
499
|
|
|
460
500
|
@util.callback
|
|
461
|
-
def async_add_job(self, target, *args):
|
|
501
|
+
def async_add_job(self, target: Any, *args: Any) -> Any:
|
|
462
502
|
"""Add job from within the event loop."""
|
|
463
503
|
task = None
|
|
464
504
|
|
|
@@ -473,7 +513,7 @@ class AccessoryDriver:
|
|
|
473
513
|
|
|
474
514
|
return task
|
|
475
515
|
|
|
476
|
-
def add_accessory(self, accessory):
|
|
516
|
+
def add_accessory(self, accessory: Accessory) -> None:
|
|
477
517
|
"""Add top level accessory to driver."""
|
|
478
518
|
self.accessory = accessory
|
|
479
519
|
if accessory.aid is None:
|
|
@@ -483,12 +523,16 @@ class AccessoryDriver:
|
|
|
483
523
|
if os.path.exists(self.persist_file):
|
|
484
524
|
logger.info("Loading Accessory state from `%s`", self.persist_file)
|
|
485
525
|
self.load()
|
|
526
|
+
# Accessories built before load() have not seen their stored state.
|
|
527
|
+
accessory.restore_accessory_state()
|
|
486
528
|
else:
|
|
487
529
|
logger.info("Storing Accessory state in `%s`", self.persist_file)
|
|
488
530
|
self.persist()
|
|
489
531
|
|
|
490
532
|
@util.callback
|
|
491
|
-
def async_subscribe_client_topic(
|
|
533
|
+
def async_subscribe_client_topic(
|
|
534
|
+
self, client: Any, topic: str, subscribe: bool = True
|
|
535
|
+
) -> None:
|
|
492
536
|
"""(Un)Subscribe the given client from the given topic.
|
|
493
537
|
|
|
494
538
|
This method must be run in the event loop.
|
|
@@ -499,9 +543,9 @@ class AccessoryDriver:
|
|
|
499
543
|
:param topic: The topic to which to subscribe.
|
|
500
544
|
:type topic: str
|
|
501
545
|
|
|
502
|
-
:param subscribe: Whether to subscribe or unsubscribe the client. Both
|
|
503
|
-
an already subscribed client and unsubscribing a client
|
|
504
|
-
do nothing.
|
|
546
|
+
:param subscribe: Whether to subscribe or unsubscribe the client. Both
|
|
547
|
+
subscribing an already subscribed client and unsubscribing a client
|
|
548
|
+
that is not subscribed do nothing.
|
|
505
549
|
:type subscribe: bool
|
|
506
550
|
"""
|
|
507
551
|
if subscribe:
|
|
@@ -519,7 +563,7 @@ class AccessoryDriver:
|
|
|
519
563
|
if not subscribed_clients:
|
|
520
564
|
del self.topics[topic]
|
|
521
565
|
|
|
522
|
-
def connection_lost(self, client):
|
|
566
|
+
def connection_lost(self, client: Any) -> None:
|
|
523
567
|
"""Called when a connection is lost to a client.
|
|
524
568
|
|
|
525
569
|
This method must be run in the event loop.
|
|
@@ -538,7 +582,12 @@ class AccessoryDriver:
|
|
|
538
582
|
self.async_subscribe_client_topic(client, topic, subscribe=False)
|
|
539
583
|
self.prepared_writes.pop(client, None)
|
|
540
584
|
|
|
541
|
-
def publish(
|
|
585
|
+
def publish(
|
|
586
|
+
self,
|
|
587
|
+
data: dict[str, Any],
|
|
588
|
+
sender_client_addr: Any = None,
|
|
589
|
+
immediate: bool = False,
|
|
590
|
+
) -> None:
|
|
542
591
|
"""Publishes an event to the client.
|
|
543
592
|
|
|
544
593
|
The publishing occurs only if the current client is subscribed to the topic for
|
|
@@ -560,11 +609,18 @@ class AccessoryDriver:
|
|
|
560
609
|
self.async_send_event, topic, data, sender_client_addr, immediate
|
|
561
610
|
)
|
|
562
611
|
|
|
563
|
-
def async_send_event(
|
|
612
|
+
def async_send_event(
|
|
613
|
+
self,
|
|
614
|
+
topic: str,
|
|
615
|
+
data: dict[str, Any],
|
|
616
|
+
sender_client_addr: Any,
|
|
617
|
+
immediate: bool,
|
|
618
|
+
) -> None:
|
|
564
619
|
"""Send an event to a client.
|
|
565
620
|
|
|
566
621
|
Must be called in the event loop
|
|
567
622
|
"""
|
|
623
|
+
assert self.aio_stop_event is not None # noqa: S101
|
|
568
624
|
if self.aio_stop_event.is_set():
|
|
569
625
|
return
|
|
570
626
|
|
|
@@ -598,25 +654,29 @@ class AccessoryDriver:
|
|
|
598
654
|
for client_addr in unsubs:
|
|
599
655
|
self.async_subscribe_client_topic(client_addr, topic, False)
|
|
600
656
|
|
|
601
|
-
def config_changed(self):
|
|
657
|
+
def config_changed(self) -> None:
|
|
602
658
|
"""Notify the driver that the accessory's configuration has changed.
|
|
603
659
|
|
|
604
660
|
Persists the accessory, so that the new configuration is available on
|
|
605
|
-
restart. Also, updates the mDNS advertisement, so that iOS clients know
|
|
606
|
-
to fetch new data.
|
|
661
|
+
restart. Also, updates the mDNS advertisement, so that iOS clients know
|
|
662
|
+
they need to fetch new data.
|
|
607
663
|
"""
|
|
608
664
|
self.state.increment_config_version()
|
|
609
665
|
self.persist()
|
|
610
666
|
self.update_advertisement()
|
|
611
667
|
|
|
612
|
-
def update_advertisement(self):
|
|
668
|
+
def update_advertisement(self) -> None:
|
|
613
669
|
"""Updates the mDNS service info for the accessory."""
|
|
614
670
|
self.loop.call_soon_threadsafe(self.async_update_advertisement)
|
|
615
671
|
|
|
616
672
|
@callback
|
|
617
|
-
def async_update_advertisement(self):
|
|
673
|
+
def async_update_advertisement(self) -> None:
|
|
618
674
|
"""Updates the mDNS service info for the accessory from the event loop."""
|
|
619
675
|
logger.debug("Updating mDNS advertisement")
|
|
676
|
+
if not self.advertise:
|
|
677
|
+
logger.debug("Not updating mDNS advertisement: advertising is disabled")
|
|
678
|
+
return
|
|
679
|
+
assert self.advertiser is not None # noqa: S101
|
|
620
680
|
self.mdns_service_info = AccessoryMDNSServiceInfo(
|
|
621
681
|
self.accessory, self.state, self.zeroconf_server
|
|
622
682
|
)
|
|
@@ -624,17 +684,24 @@ class AccessoryDriver:
|
|
|
624
684
|
self.advertiser.async_update_service(self.mdns_service_info)
|
|
625
685
|
)
|
|
626
686
|
|
|
687
|
+
def accessory_state(self, aid: int) -> dict[str, Any]:
|
|
688
|
+
"""Return the persisted state dict of accessory ``aid``.
|
|
689
|
+
|
|
690
|
+
Accessories keep settings here that must survive a restart. Save
|
|
691
|
+
changes with :meth:`async_persist`.
|
|
692
|
+
"""
|
|
693
|
+
return self.state.accessory_states.setdefault(aid, {})
|
|
694
|
+
|
|
627
695
|
@callback
|
|
628
|
-
def async_persist(self):
|
|
696
|
+
def async_persist(self) -> None:
|
|
629
697
|
"""Saves the state of the accessory.
|
|
630
698
|
|
|
631
699
|
Must be run in the event loop.
|
|
632
700
|
"""
|
|
633
|
-
loop = asyncio.get_event_loop()
|
|
634
701
|
logger.debug("Scheduling write of accessory state to disk")
|
|
635
|
-
util.async_create_background_task(loop.run_in_executor(None, self.persist))
|
|
702
|
+
util.async_create_background_task(self.loop.run_in_executor(None, self.persist))
|
|
636
703
|
|
|
637
|
-
def persist(self):
|
|
704
|
+
def persist(self) -> None:
|
|
638
705
|
"""Saves the state of the accessory.
|
|
639
706
|
|
|
640
707
|
Must run in executor.
|
|
@@ -661,19 +728,19 @@ class AccessoryDriver:
|
|
|
661
728
|
if os.path.exists(self.persist_file):
|
|
662
729
|
os.chmod(self.persist_file, 0o644)
|
|
663
730
|
os.replace(tmp_filename, self.persist_file)
|
|
664
|
-
except Exception:
|
|
731
|
+
except Exception:
|
|
665
732
|
logger.exception("Failed to persist accessory state")
|
|
666
733
|
raise
|
|
667
734
|
finally:
|
|
668
735
|
if tmp_filename and os.path.exists(tmp_filename):
|
|
669
736
|
os.remove(tmp_filename)
|
|
670
737
|
|
|
671
|
-
def load(self):
|
|
738
|
+
def load(self) -> None:
|
|
672
739
|
"""Load the persist file.
|
|
673
740
|
|
|
674
741
|
Must run in executor.
|
|
675
742
|
"""
|
|
676
|
-
with open(self.persist_file,
|
|
743
|
+
with open(self.persist_file, encoding="utf8") as file_handle:
|
|
677
744
|
self.encoder.load_into(file_handle, self.state)
|
|
678
745
|
|
|
679
746
|
@callback
|
|
@@ -704,6 +771,9 @@ class AccessoryDriver:
|
|
|
704
771
|
client_username_bytes,
|
|
705
772
|
client_permissions,
|
|
706
773
|
)
|
|
774
|
+
# A successful pairing clears the rate limiter.
|
|
775
|
+
self._failed_pairing_attempts = 0
|
|
776
|
+
self._pairing_lockout_until = 0.0
|
|
707
777
|
self.state.add_paired_client(
|
|
708
778
|
client_username_bytes, client_public, client_permissions
|
|
709
779
|
)
|
|
@@ -711,7 +781,32 @@ class AccessoryDriver:
|
|
|
711
781
|
return True
|
|
712
782
|
|
|
713
783
|
@callback
|
|
714
|
-
def
|
|
784
|
+
def register_failed_pairing_attempt(self) -> None:
|
|
785
|
+
"""Register a failed pair-setup attempt.
|
|
786
|
+
|
|
787
|
+
Each failed SRP proof is effectively a setup-code guess and each pair
|
|
788
|
+
setup also regenerates a 3072-bit SRP verifier, so after too many
|
|
789
|
+
consecutive failures pair-setup is locked out for a cooldown period.
|
|
790
|
+
|
|
791
|
+
This method must be run in the event loop.
|
|
792
|
+
"""
|
|
793
|
+
self._failed_pairing_attempts += 1
|
|
794
|
+
if self._failed_pairing_attempts >= MAX_FAILED_PAIRING_ATTEMPTS:
|
|
795
|
+
self._pairing_lockout_until = time.monotonic() + PAIRING_LOCKOUT_SECONDS
|
|
796
|
+
self._failed_pairing_attempts = 0
|
|
797
|
+
logger.warning(
|
|
798
|
+
"Pair-setup locked out for %s seconds after %s failed attempts",
|
|
799
|
+
PAIRING_LOCKOUT_SECONDS,
|
|
800
|
+
MAX_FAILED_PAIRING_ATTEMPTS,
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
@property
|
|
804
|
+
def pairing_locked_out(self) -> bool:
|
|
805
|
+
"""Whether pair-setup is currently locked out due to failed attempts."""
|
|
806
|
+
return time.monotonic() < self._pairing_lockout_until
|
|
807
|
+
|
|
808
|
+
@callback
|
|
809
|
+
def unpair(self, client_uuid: Any) -> None:
|
|
715
810
|
"""Removes the paired client from the accessory.
|
|
716
811
|
|
|
717
812
|
Persist the new accessory state.
|
|
@@ -721,9 +816,15 @@ class AccessoryDriver:
|
|
|
721
816
|
"""
|
|
722
817
|
logger.info("Unpairing client %s.", client_uuid)
|
|
723
818
|
self.state.remove_paired_client(client_uuid)
|
|
819
|
+
if not self.state.paired and self.accessory is not None:
|
|
820
|
+
try:
|
|
821
|
+
self.accessory.reset_accessory_state()
|
|
822
|
+
except Exception:
|
|
823
|
+
# The removed pairing must be saved regardless.
|
|
824
|
+
logger.warning("Failed to reset the accessory state", exc_info=True)
|
|
724
825
|
self.async_persist()
|
|
725
826
|
|
|
726
|
-
def finish_pair(self):
|
|
827
|
+
def finish_pair(self) -> None:
|
|
727
828
|
"""Finishing pairing or unpairing.
|
|
728
829
|
|
|
729
830
|
Updates the accessory and updates the mDNS service.
|
|
@@ -743,15 +844,21 @@ class AccessoryDriver:
|
|
|
743
844
|
if not self.safe_mode:
|
|
744
845
|
self.update_advertisement()
|
|
745
846
|
|
|
746
|
-
|
|
847
|
+
@property
|
|
848
|
+
def srp_verifier(self) -> SrpServer:
|
|
849
|
+
"""Return the SRP verifier (created by setup_srp_verifier)."""
|
|
850
|
+
assert self._srp_verifier is not None # noqa: S101
|
|
851
|
+
return self._srp_verifier
|
|
852
|
+
|
|
853
|
+
def setup_srp_verifier(self) -> None:
|
|
747
854
|
"""Create an SRP verifier for the accessory's info."""
|
|
748
855
|
# TODO: Move the below hard-coded values somewhere nice.
|
|
749
856
|
ctx = get_srp_context(3072, hashlib.sha512, 16)
|
|
750
857
|
verifier = SrpServer(ctx, b"Pair-Setup", self.state.pincode)
|
|
751
|
-
self.
|
|
858
|
+
self._srp_verifier = verifier
|
|
752
859
|
|
|
753
860
|
@property
|
|
754
|
-
def accessories_hash(self):
|
|
861
|
+
def accessories_hash(self) -> str:
|
|
755
862
|
"""Hash the get_accessories response to track configuration changes."""
|
|
756
863
|
# We pass include_value=False to avoid including the value
|
|
757
864
|
# of the characteristics in the hash. This is because the
|
|
@@ -764,7 +871,7 @@ class AccessoryDriver:
|
|
|
764
871
|
util.to_sorted_hap_json(self.get_accessories(include_value=False))
|
|
765
872
|
).hexdigest()
|
|
766
873
|
|
|
767
|
-
def get_accessories(self, include_value: bool = True):
|
|
874
|
+
def get_accessories(self, include_value: bool = True) -> dict[str, Any]:
|
|
768
875
|
"""Returns the accessory in HAP format.
|
|
769
876
|
|
|
770
877
|
:return: An example HAP representation is:
|
|
@@ -789,6 +896,7 @@ class AccessoryDriver:
|
|
|
789
896
|
|
|
790
897
|
:rtype: dict
|
|
791
898
|
"""
|
|
899
|
+
assert self.accessory is not None # noqa: S101
|
|
792
900
|
hap_rep = self.accessory.to_HAP(include_value=include_value)
|
|
793
901
|
if not isinstance(hap_rep, list):
|
|
794
902
|
hap_rep = [
|
|
@@ -797,7 +905,7 @@ class AccessoryDriver:
|
|
|
797
905
|
logger.debug("Get accessories response: %s", hap_rep)
|
|
798
906
|
return {HAP_REPR_ACCS: hap_rep}
|
|
799
907
|
|
|
800
|
-
def get_characteristics(self, char_ids):
|
|
908
|
+
def get_characteristics(self, char_ids: Any) -> dict[str, Any]:
|
|
801
909
|
"""Returns values for the required characteristics.
|
|
802
910
|
|
|
803
911
|
:param char_ids: A list of characteristic "paths", e.g. "1.2" is aid 1, iid 2.
|
|
@@ -817,6 +925,7 @@ class AccessoryDriver:
|
|
|
817
925
|
|
|
818
926
|
:rtype: dict
|
|
819
927
|
"""
|
|
928
|
+
assert self.accessory is not None # noqa: S101
|
|
820
929
|
chars = []
|
|
821
930
|
for aid_iid in char_ids:
|
|
822
931
|
aid, iid = (int(i) for i in aid_iid.split("."))
|
|
@@ -829,15 +938,25 @@ class AccessoryDriver:
|
|
|
829
938
|
try:
|
|
830
939
|
if aid == STANDALONE_AID:
|
|
831
940
|
char = self.accessory.iid_manager.get_obj(iid)
|
|
832
|
-
available =
|
|
941
|
+
available = self.accessory.available
|
|
833
942
|
else:
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
943
|
+
# Accessory IIDs other than the standalone one only exist
|
|
944
|
+
# on bridged accessories; anything else is unknown and
|
|
945
|
+
# keeps the per-characteristic failure status.
|
|
946
|
+
bridge = self.accessory
|
|
947
|
+
if isinstance(bridge, Bridge):
|
|
948
|
+
acc = bridge.accessories.get(aid)
|
|
949
|
+
if acc is None:
|
|
950
|
+
continue
|
|
951
|
+
char = acc.iid_manager.get_obj(iid)
|
|
952
|
+
available = acc.available
|
|
953
|
+
else:
|
|
954
|
+
char = None
|
|
955
|
+
available = False
|
|
956
|
+
|
|
957
|
+
# A characteristic IID may resolve to a Service or be unknown;
|
|
958
|
+
# only a Characteristic can report a value.
|
|
959
|
+
if available and isinstance(char, Characteristic):
|
|
841
960
|
rep[HAP_REPR_VALUE] = char.get_value()
|
|
842
961
|
rep[HAP_REPR_STATUS] = HAP_SERVER_STATUS.SUCCESS
|
|
843
962
|
except CharacteristicError:
|
|
@@ -846,7 +965,7 @@ class AccessoryDriver:
|
|
|
846
965
|
self.accessory.display_name,
|
|
847
966
|
(aid, iid),
|
|
848
967
|
)
|
|
849
|
-
except Exception:
|
|
968
|
+
except Exception:
|
|
850
969
|
logger.exception(
|
|
851
970
|
"%s: Unexpected error getting value for characteristic %s.",
|
|
852
971
|
self.accessory.display_name,
|
|
@@ -857,7 +976,26 @@ class AccessoryDriver:
|
|
|
857
976
|
logger.debug("Get chars response: %s", chars)
|
|
858
977
|
return {HAP_REPR_CHARS: chars}
|
|
859
978
|
|
|
860
|
-
def
|
|
979
|
+
def derive_session_key(
|
|
980
|
+
self, client_addr: tuple[str, int] | None, salt: bytes, info: bytes
|
|
981
|
+
) -> bytes | None:
|
|
982
|
+
"""Derive a key from the pair-verify session key of ``client_addr``.
|
|
983
|
+
|
|
984
|
+
For transports set up from the HAP session, e.g. HomeKit Data Stream.
|
|
985
|
+
Never returns the session key itself, see
|
|
986
|
+
:meth:`HAPServerProtocol.derive_key`. Returns ``None`` when the
|
|
987
|
+
connection is unknown or not encrypted.
|
|
988
|
+
"""
|
|
989
|
+
if client_addr is None:
|
|
990
|
+
return None
|
|
991
|
+
connection = self.http_server.connections.get(client_addr)
|
|
992
|
+
if connection is None:
|
|
993
|
+
return None
|
|
994
|
+
return connection.derive_key(salt, info)
|
|
995
|
+
|
|
996
|
+
def set_characteristics(
|
|
997
|
+
self, chars_query: dict[str, Any], client_addr: tuple[str, int]
|
|
998
|
+
) -> dict[str, Any] | None:
|
|
861
999
|
"""Called from ``HAPServerHandler`` when iOS configures the characteristics.
|
|
862
1000
|
|
|
863
1001
|
:param chars_query: A configuration query. For example:
|
|
@@ -878,17 +1016,17 @@ class AccessoryDriver:
|
|
|
878
1016
|
"""
|
|
879
1017
|
# TODO: Add support for chars that do no support notifications.
|
|
880
1018
|
|
|
881
|
-
queries:
|
|
1019
|
+
queries: list[dict[str, Any]] = chars_query[HAP_REPR_CHARS]
|
|
882
1020
|
|
|
883
1021
|
self._notify(queries, client_addr)
|
|
884
1022
|
|
|
885
|
-
updates_by_accessories_services:
|
|
886
|
-
Accessory,
|
|
1023
|
+
updates_by_accessories_services: dict[
|
|
1024
|
+
Accessory, dict[Service, dict[Characteristic, Any]]
|
|
887
1025
|
] = defaultdict(lambda: defaultdict(dict))
|
|
888
|
-
results:
|
|
1026
|
+
results: dict[int, dict[int, dict[str, Any]]] = defaultdict(
|
|
889
1027
|
lambda: defaultdict(dict)
|
|
890
1028
|
)
|
|
891
|
-
char_to_iid:
|
|
1029
|
+
char_to_iid: dict[Characteristic, int] = {}
|
|
892
1030
|
|
|
893
1031
|
expired = False
|
|
894
1032
|
if HAP_REPR_PID in chars_query:
|
|
@@ -897,6 +1035,7 @@ class AccessoryDriver:
|
|
|
897
1035
|
expired = expire_time is None or time.time() > expire_time
|
|
898
1036
|
|
|
899
1037
|
primary_accessory = self.accessory
|
|
1038
|
+
assert primary_accessory is not None # noqa: S101
|
|
900
1039
|
primary_aid = primary_accessory.aid
|
|
901
1040
|
|
|
902
1041
|
for query in queries:
|
|
@@ -911,8 +1050,17 @@ class AccessoryDriver:
|
|
|
911
1050
|
if aid == primary_aid:
|
|
912
1051
|
acc = primary_accessory
|
|
913
1052
|
else:
|
|
914
|
-
|
|
1053
|
+
# Accessory IIDs other than the primary one only exist on
|
|
1054
|
+
# bridged accessories.
|
|
1055
|
+
bridge = self.accessory
|
|
1056
|
+
if not isinstance(bridge, Bridge):
|
|
1057
|
+
continue
|
|
1058
|
+
acc = bridge.accessories.get(aid)
|
|
1059
|
+
if acc is None:
|
|
1060
|
+
continue
|
|
915
1061
|
char = acc.get_characteristic(aid, iid)
|
|
1062
|
+
if char is None:
|
|
1063
|
+
continue
|
|
916
1064
|
|
|
917
1065
|
set_result = HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST
|
|
918
1066
|
set_result_value = None
|
|
@@ -930,11 +1078,13 @@ class AccessoryDriver:
|
|
|
930
1078
|
results[aid][iid] = result
|
|
931
1079
|
char_to_iid[char] = iid
|
|
932
1080
|
service = char.service
|
|
1081
|
+
assert service is not None # noqa: S101
|
|
933
1082
|
updates_by_accessories_services[acc][service][char] = value
|
|
934
1083
|
|
|
935
1084
|
# Proccess accessory and service level setter callbacks
|
|
936
1085
|
for acc, updates_by_service in updates_by_accessories_services.items():
|
|
937
1086
|
aid = acc.aid
|
|
1087
|
+
assert aid is not None # noqa: S101
|
|
938
1088
|
aid_results = results[aid]
|
|
939
1089
|
|
|
940
1090
|
# Accessory level setter callbacks
|
|
@@ -970,7 +1120,9 @@ class AccessoryDriver:
|
|
|
970
1120
|
|
|
971
1121
|
return {HAP_REPR_CHARS: characteristics} if nonempty_results_exist else None
|
|
972
1122
|
|
|
973
|
-
def prepare(
|
|
1123
|
+
def prepare(
|
|
1124
|
+
self, prepare_query: dict[str, Any], client_addr: tuple[str, int]
|
|
1125
|
+
) -> dict[str, int]:
|
|
974
1126
|
"""Called from ``HAPServerHandler`` when iOS wants to prepare a write.
|
|
975
1127
|
|
|
976
1128
|
:param prepare_query: A prepare query. For example:
|
|
@@ -987,15 +1139,26 @@ class AccessoryDriver:
|
|
|
987
1139
|
try:
|
|
988
1140
|
ttl = prepare_query[HAP_REPR_TTL]
|
|
989
1141
|
pid = prepare_query[HAP_REPR_PID]
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1142
|
+
now = time.time()
|
|
1143
|
+
pending = self.prepared_writes.setdefault(client_addr, {})
|
|
1144
|
+
# Prune expired entries and cap the number of pending writes so a
|
|
1145
|
+
# paired client cannot grow prepared_writes without bound by
|
|
1146
|
+
# registering endless unique pids with arbitrary TTLs.
|
|
1147
|
+
for stale_pid in [
|
|
1148
|
+
prepared_pid
|
|
1149
|
+
for prepared_pid, expires_at in pending.items()
|
|
1150
|
+
if expires_at <= now
|
|
1151
|
+
]:
|
|
1152
|
+
del pending[stale_pid]
|
|
1153
|
+
if len(pending) >= MAX_PREPARED_WRITES_PER_CLIENT:
|
|
1154
|
+
del pending[min(pending, key=pending.get)]
|
|
1155
|
+
pending[pid] = now + min(ttl / 1000, MAX_PREPARED_WRITE_TTL_SECONDS)
|
|
1156
|
+
except (KeyError, TypeError, ValueError):
|
|
994
1157
|
return {HAP_REPR_STATUS: HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST}
|
|
995
1158
|
|
|
996
1159
|
return {HAP_REPR_STATUS: HAP_SERVER_STATUS.SUCCESS}
|
|
997
1160
|
|
|
998
|
-
def signal_handler(self, _signal, _frame):
|
|
1161
|
+
def signal_handler(self, _signal: Any, _frame: Any) -> None:
|
|
999
1162
|
"""Stops the AccessoryDriver for a given signal.
|
|
1000
1163
|
|
|
1001
1164
|
An AccessoryDriver can be registered as a signal handler with this method. For
|
|
@@ -1003,8 +1166,8 @@ class AccessoryDriver:
|
|
|
1003
1166
|
>>> import signal
|
|
1004
1167
|
>>> signal.signal(signal.SIGINT, anAccDriver.signal_handler)
|
|
1005
1168
|
|
|
1006
|
-
Now, when the user hits Ctrl+C, the driver will stop its accessory, the
|
|
1007
|
-
and everything else that needs stopping and will exit gracefully.
|
|
1169
|
+
Now, when the user hits Ctrl+C, the driver will stop its accessory, the
|
|
1170
|
+
HAP server and everything else that needs stopping and will exit gracefully.
|
|
1008
1171
|
"""
|
|
1009
1172
|
try:
|
|
1010
1173
|
self.stop()
|
|
@@ -1013,7 +1176,7 @@ class AccessoryDriver:
|
|
|
1013
1176
|
raise
|
|
1014
1177
|
|
|
1015
1178
|
def _notify(
|
|
1016
|
-
self, queries:
|
|
1179
|
+
self, queries: list[dict[str, Any]], client_addr: tuple[str, int]
|
|
1017
1180
|
) -> None:
|
|
1018
1181
|
"""Notify the driver that the client has subscribed or unsubscribed."""
|
|
1019
1182
|
for query in queries:
|