ipguard-api 0.1.0b6__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.
ipguard_api/client.py ADDED
@@ -0,0 +1,833 @@
1
+ """Authorized enrollment, pushed relay discovery and single-attempt release."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections import OrderedDict
6
+ from collections.abc import Awaitable, Callable
7
+ from dataclasses import dataclass, replace
8
+ from functools import wraps
9
+ from struct import error as StructError
10
+ from time import monotonic
11
+ from types import TracebackType
12
+ from typing import Concatenate
13
+ from uuid import UUID, uuid4
14
+
15
+ from zmq.utils import z85
16
+
17
+ from .errors import (
18
+ AuthenticationError,
19
+ AuthRevokedError,
20
+ ConnectionFailureError,
21
+ ExistingInstallationError,
22
+ InvalidCodeError,
23
+ InvalidInputError,
24
+ IpguardError,
25
+ OutcomeUnknownError,
26
+ RelayUnavailableError,
27
+ ReleaseRejectedError,
28
+ )
29
+ from .models import (
30
+ DEFAULT_ACTIVATION_ENDPOINT,
31
+ DEFAULT_SECURE_ENDPOINT,
32
+ AndroidProfile,
33
+ AuthIdentifier,
34
+ Board,
35
+ ClientSnapshot,
36
+ IncomingCall,
37
+ Relay,
38
+ Session,
39
+ SessionStatus,
40
+ )
41
+ from .protocol import (
42
+ Packet,
43
+ configuration_password,
44
+ door_inventory,
45
+ envelope,
46
+ hangup_call,
47
+ hello,
48
+ identifier_fields,
49
+ incoming_call,
50
+ packet_body,
51
+ packet_event,
52
+ packet_sender,
53
+ )
54
+ from .transport import ZmqTransport
55
+ from .validation import (
56
+ validate_duration,
57
+ validate_identifier,
58
+ validate_installation_label,
59
+ validate_routing_identity,
60
+ validate_verification_code,
61
+ )
62
+
63
+ type Listener = Callable[[ClientSnapshot], None]
64
+ type CallListener = Callable[[IncomingCall], None]
65
+ MAX_RECENT_CALLS = 256
66
+ RECENT_CALL_TTL = 15 * 60
67
+ AUTO_HANGUP_TTL = 5.0
68
+ _LOGGER = logging.getLogger(__name__)
69
+ _LOGGED_EVENTS = frozenset(
70
+ {
71
+ "srv_sp_delete",
72
+ "bc_sp_delete_app",
73
+ "bc_sp_relays",
74
+ "srv_sp_update",
75
+ "srv_sp_check_auth_result",
76
+ "srv_sp_validate_auth_result",
77
+ "srv_sp_ask_key_result",
78
+ "srv_sp_check_username_result",
79
+ "brd_sp_open_door_ack",
80
+ }
81
+ )
82
+
83
+
84
+ @dataclass(eq=False, repr=False, slots=True)
85
+ class _AutomaticHangup:
86
+ transport: ZmqTransport
87
+ token: str
88
+ remote: str
89
+ sender: str
90
+ deadline: float
91
+
92
+
93
+ def _cancel_cleanup[**P, R](
94
+ method: Callable[Concatenate[IpguardClient, P], Awaitable[R]],
95
+ ) -> Callable[Concatenate[IpguardClient, P], Awaitable[R]]:
96
+ @wraps(method)
97
+ async def wrapped(client: IpguardClient, /, *args: P.args, **kwargs: P.kwargs) -> R:
98
+ try:
99
+ return await method(client, *args, **kwargs)
100
+ except asyncio.CancelledError:
101
+ await client.async_close()
102
+ raise
103
+
104
+ return wrapped
105
+
106
+
107
+ class IpguardClient:
108
+ """Maintain one installation's cloud connection; getters perform no IO."""
109
+
110
+ def __init__(
111
+ self,
112
+ timeout: float = 10.0,
113
+ *,
114
+ session: Session | None = None,
115
+ activation_endpoint: str = DEFAULT_ACTIVATION_ENDPOINT,
116
+ secure_endpoint: str = DEFAULT_SECURE_ENDPOINT,
117
+ reconnect_delay: float = 1.0,
118
+ max_reconnect_delay: float = 30.0,
119
+ auto_hangup: bool = False,
120
+ ) -> None:
121
+ if type(auto_hangup) is not bool:
122
+ raise InvalidInputError("Automatic hangup must be a boolean.")
123
+ self.timeout = validate_duration(timeout, 300)
124
+ reconnect_delay = validate_duration(reconnect_delay, 3600)
125
+ max_reconnect_delay = validate_duration(max_reconnect_delay, 3600)
126
+ if max_reconnect_delay < reconnect_delay:
127
+ raise InvalidInputError(
128
+ "The maximum reconnect delay must not be shorter than the initial delay."
129
+ )
130
+ self._session = (
131
+ replace(session, android_profile=AndroidProfile()) if session else None
132
+ )
133
+ self._activation_endpoint = (
134
+ session.activation_endpoint if session else activation_endpoint
135
+ )
136
+ self._secure_endpoint = session.secure_endpoint if session else secure_endpoint
137
+ self._reconnect_delay, self._max_reconnect_delay = (
138
+ reconnect_delay,
139
+ max_reconnect_delay,
140
+ )
141
+ self._transport: ZmqTransport | None = None
142
+ self._reconnect_task: asyncio.Task[None] | None = None
143
+ self._listeners: set[Listener] = set()
144
+ self._call_listeners: set[CallListener] = set()
145
+ self._call_tokens: OrderedDict[bytes, float] = OrderedDict()
146
+ self._call_boards: dict[str, Board] = {}
147
+ self._auto_hangup = auto_hangup
148
+ self._hangup_call: _AutomaticHangup | None = None
149
+ self._hangup_task: asyncio.Task[None] | None = None
150
+ self._relays: tuple[Relay, ...] = ()
151
+ self._connected = False
152
+ self._revoked = False
153
+ self._error: IpguardError | None = None
154
+ self._closed = False
155
+ self._inventory_ready = asyncio.Event()
156
+ self._configuration_ready = asyncio.Event()
157
+ self._failure_ready = asyncio.Event()
158
+ self._identifier: AuthIdentifier | None = None
159
+ self._identity = ""
160
+ self._label = ""
161
+ self._verification_requested = False
162
+ self._release_locks: dict[tuple[str, int], asyncio.Lock] = {}
163
+
164
+ @property
165
+ def connected(self) -> bool:
166
+ return self._connected
167
+
168
+ @property
169
+ def session(self) -> Session | None:
170
+ return self._session
171
+
172
+ @property
173
+ def snapshot(self) -> ClientSnapshot:
174
+ return ClientSnapshot(
175
+ self._relays, self._connected, self._revoked, self._error, self._session
176
+ )
177
+
178
+ def subscribe(self, callback: Listener) -> Callable[[], None]:
179
+ """Register a synchronous event-loop callback; return its removal function."""
180
+ self._listeners.add(callback)
181
+ return lambda: self._listeners.discard(callback)
182
+
183
+ def subscribe_calls(self, callback: CallListener) -> Callable[[], None]:
184
+ """Subscribe to sanitized incoming offers.
185
+
186
+ Callbacks run synchronously on the event loop. Notifications are not
187
+ replayed to later subscribers. The removal function is idempotent.
188
+ Duplicate tokens are remembered across reconnects for 15 minutes after
189
+ their last receipt, subject to a limit of 256 recent tokens.
190
+ The default is passive; enabling auto_hangup sends one
191
+ hangup attempt after notification, without answering or negotiating media.
192
+ """
193
+ self._call_listeners.add(callback)
194
+ return lambda: self._call_listeners.discard(callback)
195
+
196
+ def _notify(self) -> None:
197
+ snapshot = self.snapshot
198
+ for callback in tuple(self._listeners):
199
+ try:
200
+ callback(snapshot)
201
+ except Exception:
202
+ continue
203
+
204
+ @_cancel_cleanup
205
+ async def async_request_verification(self, identifier: str, label: str) -> None:
206
+ auth = validate_identifier(identifier)
207
+ label = validate_installation_label(label)
208
+ await self._stop_connection()
209
+ old = self._session
210
+ previous_identifier, previous_identity = self._identifier, self._identity
211
+ same_account = (
212
+ old is not None
213
+ and old.identifier.kind == auth.kind
214
+ and old.identifier.value.casefold() == auth.value.casefold()
215
+ )
216
+ same_pending = (
217
+ previous_identifier is not None
218
+ and previous_identifier.kind == auth.kind
219
+ and previous_identifier.value.casefold() == auth.value.casefold()
220
+ )
221
+ self._identity = (
222
+ old.identity
223
+ if same_account and old
224
+ else (
225
+ previous_identity
226
+ if same_pending and previous_identity
227
+ else str(uuid4())
228
+ )
229
+ )
230
+ if self._identity != (old.identity if old else previous_identity):
231
+ self._call_tokens.clear()
232
+ self._call_boards.clear()
233
+ self._identifier, self._label = auth, label
234
+ self._session = None
235
+ self._revoked, self._closed, self._error = False, False, None
236
+ self._verification_requested = False
237
+ self._relays = ()
238
+ _LOGGER.debug("IPGUARD stage verification_request started")
239
+ transport = await self._new_transport(secure=False)
240
+ await transport.async_send(hello(self._identity, auth, None))
241
+ await self._auth_request(
242
+ "sp_srv_check_auth", identifier_fields(auth), "srv_sp_check_auth_result"
243
+ )
244
+ self._verification_requested = True
245
+
246
+ @_cancel_cleanup
247
+ async def async_verify_code(self, code: str) -> None:
248
+ code = validate_verification_code(code)
249
+ if not self._verification_requested or self._identifier is None:
250
+ raise AuthenticationError(
251
+ "Request a verification code before verifying it."
252
+ )
253
+ if (
254
+ self._transport is None
255
+ or self._transport.closed
256
+ or self._transport.server_key is not None
257
+ ):
258
+ transport = await self._new_transport(secure=False)
259
+ await transport.async_send(hello(self._identity, self._identifier, None))
260
+ fields = {**identifier_fields(self._identifier), "code": code}
261
+ _LOGGER.debug("IPGUARD stage verify_code started")
262
+ await self._auth_request(
263
+ "sp_srv_validate_auth", fields, "srv_sp_validate_auth_result"
264
+ )
265
+ body = await self._auth_request(
266
+ "sp_srv_ask_key", fields, "srv_sp_ask_key_result"
267
+ )
268
+ key = body.get("key")
269
+ if not isinstance(key, str) or not _valid_curve_key(key):
270
+ raise AuthenticationError("IPGUARD returned an invalid server public key.")
271
+ self._session = Session(
272
+ identity=self._identity,
273
+ identifier=self._identifier,
274
+ label=self._label,
275
+ verification_code=code,
276
+ server_public_key=key,
277
+ activation_endpoint=self._activation_endpoint,
278
+ secure_endpoint=self._secure_endpoint,
279
+ status=SessionStatus.PENDING,
280
+ )
281
+
282
+ @_cancel_cleanup
283
+ async def async_complete_registration(
284
+ self, *, replace_existing: bool = False
285
+ ) -> Session:
286
+ session = self._session
287
+ if session is None or session.status is not SessionStatus.PENDING:
288
+ raise AuthenticationError("Verify a code before completing registration.")
289
+ if not isinstance(replace_existing, bool):
290
+ raise InvalidInputError("Replacement consent must be an explicit boolean.")
291
+ if (
292
+ self._transport is None
293
+ or self._transport.closed
294
+ or self._transport.server_key is None
295
+ ):
296
+ transport = await self._new_transport(secure=True)
297
+ await transport.async_send(
298
+ hello(session.identity, session.identifier, session)
299
+ )
300
+ fields = {
301
+ **identifier_fields(session.identifier),
302
+ "code": session.verification_code,
303
+ "username": validate_installation_label(session.label),
304
+ "model": AndroidProfile().model,
305
+ }
306
+ _LOGGER.debug("IPGUARD stage registration started")
307
+ result = await self._register("sp_srv_check_username", fields)
308
+ if isinstance(result, int) and not isinstance(result, bool) and result == 0:
309
+ if not replace_existing:
310
+ raise ExistingInstallationError(
311
+ "Registration requires consent to replace an existing mobile installation."
312
+ )
313
+ result = await self._register("sp_srv_override_username", fields)
314
+ if (
315
+ not isinstance(result, int)
316
+ or isinstance(result, bool)
317
+ or result not in (1, 2)
318
+ ):
319
+ raise AuthenticationError("IPGUARD rejected installation registration.")
320
+ await self._wait_ready(self._configuration_ready, "activation configuration")
321
+ await self._wait_ready(self._inventory_ready, "relay inventory")
322
+ assert self._session is not None
323
+ self._session = replace(self._session, status=SessionStatus.ACTIVATED)
324
+ self._connected, self._error = True, None
325
+ self._notify()
326
+ return self._session
327
+
328
+ @_cancel_cleanup
329
+ async def async_connect(self, session: Session) -> None:
330
+ validate_installation_label(session.label)
331
+ validate_verification_code(session.verification_code)
332
+ auth = validate_identifier(session.identifier.value)
333
+ try:
334
+ UUID(session.identity)
335
+ except ValueError, AttributeError:
336
+ raise InvalidInputError("Invalid saved installation identity.") from None
337
+ if (
338
+ auth != session.identifier
339
+ or not _valid_curve_key(session.server_public_key)
340
+ or not session.sip_password
341
+ ):
342
+ raise AuthenticationError("Saved installation authorization is incomplete.")
343
+ if session.status is SessionStatus.DISABLED:
344
+ raise AuthRevokedError(
345
+ "This installation requires a new user-driven enrollment."
346
+ )
347
+ if session.status is not SessionStatus.ACTIVATED:
348
+ raise AuthenticationError("Saved installation enrollment is incomplete.")
349
+ await self._stop_connection()
350
+ if self._session is None or self._session.identity != session.identity:
351
+ self._call_tokens.clear()
352
+ self._call_boards.clear()
353
+ self._closed, self._revoked, self._error = False, False, None
354
+ self._session = replace(session, android_profile=AndroidProfile())
355
+ self._identity, self._identifier, self._label = (
356
+ session.identity,
357
+ auth,
358
+ session.label,
359
+ )
360
+ self._activation_endpoint, self._secure_endpoint = (
361
+ session.activation_endpoint,
362
+ session.secure_endpoint,
363
+ )
364
+ await self._connect_saved()
365
+
366
+ async def _connect_saved(self) -> None:
367
+ assert self._session is not None
368
+ transport = await self._new_transport(secure=True)
369
+ await transport.async_send(
370
+ hello(self._session.identity, self._session.identifier, self._session)
371
+ )
372
+ await self._wait_ready(self._inventory_ready, "relay inventory")
373
+ if self._revoked:
374
+ raise AuthRevokedError("IPGUARD revoked this installation.")
375
+ self._connected, self._error = True, None
376
+ self._notify()
377
+
378
+ async def async_get_relays(self) -> tuple[Relay, ...]:
379
+ self._require_connected()
380
+ return self._relays
381
+
382
+ async def async_release(self, board_identity: str, relay: int) -> None:
383
+ validate_routing_identity(board_identity)
384
+ if (
385
+ not isinstance(board_identity, str)
386
+ or not board_identity
387
+ or isinstance(relay, bool)
388
+ or not isinstance(relay, int)
389
+ or not 1 <= relay <= 3
390
+ ):
391
+ raise InvalidInputError(
392
+ "Select a board identity and a one-based relay number from 1 to 3."
393
+ )
394
+ self._require_release_permission(board_identity, relay)
395
+ lock = self._release_locks.setdefault((board_identity, relay), asyncio.Lock())
396
+ if lock.locked():
397
+ raise RelayUnavailableError(
398
+ "A release request is already in progress for this relay."
399
+ )
400
+ try:
401
+ async with lock:
402
+ self._require_release_permission(board_identity, relay)
403
+ transport, session = self._transport, self._session
404
+ assert transport is not None and session is not None
405
+ packet = envelope(session.identity, "sp_brd_open_door", {"door": relay})
406
+ token = packet_body(packet)["token"]
407
+ attempted = False
408
+
409
+ def before_send() -> None:
410
+ nonlocal attempted
411
+ self._require_release_permission(board_identity, relay)
412
+ if self._transport is not transport:
413
+ raise ConnectionFailureError(
414
+ "The connection changed; no release was sent."
415
+ )
416
+ attempted = True
417
+
418
+ _LOGGER.debug("IPGUARD stage release started")
419
+ try:
420
+ reply = await transport.async_request(
421
+ packet,
422
+ lambda p: (
423
+ packet_event(p) == "brd_sp_open_door_ack"
424
+ and packet_body(p).get("token") == token
425
+ and packet_sender(p) == board_identity
426
+ ),
427
+ board_identity,
428
+ before_send=before_send,
429
+ )
430
+ except ConnectionFailureError:
431
+ if not attempted:
432
+ raise
433
+ raise OutcomeUnknownError(
434
+ "Release outcome is unknown; the command was not retried."
435
+ ) from None
436
+ body = packet_body(reply)
437
+ if "result" in body and (
438
+ isinstance(body["result"], bool)
439
+ or not isinstance(body["result"], int)
440
+ or body["result"] != 1
441
+ ):
442
+ raise ReleaseRejectedError(
443
+ "The board rejected the release request."
444
+ )
445
+ status = body.get("ble_frame_status")
446
+ if status and (
447
+ not isinstance(status, str) or not status.startswith("_teOK_")
448
+ ):
449
+ raise ReleaseRejectedError(
450
+ "The board reported an unsuccessful release acknowledgement."
451
+ )
452
+ finally:
453
+ if self._release_locks.get((board_identity, relay)) is lock:
454
+ self._release_locks.pop((board_identity, relay))
455
+
456
+ def _require_release_permission(self, board_identity: str, relay: int) -> None:
457
+ self._require_connected()
458
+ selected = next(
459
+ (
460
+ item
461
+ for item in self._relays
462
+ if item.board_identity == board_identity and item.number == relay
463
+ ),
464
+ None,
465
+ )
466
+ if (
467
+ selected is None
468
+ or not selected.remote_allowed
469
+ or selected.pending_confirmation
470
+ ):
471
+ raise RelayUnavailableError(
472
+ "This relay is not currently permitted for remote release."
473
+ )
474
+
475
+ def _require_connected(self) -> None:
476
+ if self._revoked:
477
+ raise AuthRevokedError("IPGUARD revoked this activated installation.")
478
+ if not self._connected or self._transport is None:
479
+ raise ConnectionFailureError(
480
+ "IPGUARD is not connected; no release was attempted."
481
+ )
482
+
483
+ async def _auth_request(
484
+ self, event: str, fields: dict[str, object], response: str
485
+ ) -> dict[str, object]:
486
+ if self._transport is None:
487
+ raise ConnectionFailureError("IPGUARD is not connected.")
488
+ packet = envelope(self._identity, event, fields)
489
+ token = packet_body(packet)["token"]
490
+ reply = await self._transport.async_request(
491
+ packet,
492
+ lambda p: (
493
+ packet_event(p) == response and packet_body(p).get("token") == token
494
+ ),
495
+ )
496
+ body = packet_body(reply)
497
+ result = body.get("result")
498
+ if isinstance(result, bool) or not isinstance(result, int) or result != 1:
499
+ if (
500
+ event == "sp_srv_validate_auth"
501
+ and isinstance(result, int)
502
+ and not isinstance(result, bool)
503
+ and result == 0
504
+ ):
505
+ raise InvalidCodeError("IPGUARD rejected the verification code.")
506
+ raise AuthenticationError("IPGUARD rejected the authorization request.")
507
+ return body
508
+
509
+ async def _register(self, event: str, fields: dict[str, object]) -> object:
510
+ assert self._transport is not None
511
+ packet = envelope(self._identity, event, fields)
512
+ token = packet_body(packet)["token"]
513
+ reply = await self._transport.async_request(
514
+ packet,
515
+ lambda p: (
516
+ (
517
+ packet_event(p) == "srv_sp_check_username_result"
518
+ and packet_body(p).get("token") == token
519
+ )
520
+ or (
521
+ packet_event(p) == "srv_sp_update"
522
+ and self._configuration_ready.is_set()
523
+ )
524
+ ),
525
+ )
526
+ return (
527
+ 2
528
+ if packet_event(reply) == "srv_sp_update"
529
+ else packet_body(reply).get("result")
530
+ )
531
+
532
+ async def _new_transport(self, *, secure: bool) -> ZmqTransport:
533
+ await self._stop_auto_hangup()
534
+ if self._transport is not None:
535
+ await self._transport.async_close()
536
+ self._connected = False
537
+ self._inventory_ready.clear()
538
+ self._error = None
539
+ self._failure_ready.clear()
540
+ self._configuration_ready.clear()
541
+ key = self._session.server_public_key if secure and self._session else None
542
+
543
+ async def receive(packet: Packet) -> None:
544
+ if packet_event(packet) == "ps_sp_sdp_offer":
545
+ self._receive_call(packet, transport)
546
+ else:
547
+ self._receive_call_termination(packet, transport)
548
+ await self._on_packet(packet)
549
+
550
+ transport = ZmqTransport(
551
+ self._identity,
552
+ self._secure_endpoint if secure else self._activation_endpoint,
553
+ self.timeout,
554
+ receive,
555
+ self._on_failure,
556
+ key,
557
+ )
558
+ self._transport = transport
559
+ await transport.async_start()
560
+ return transport
561
+
562
+ def _call_transport_current(self, transport: ZmqTransport) -> bool:
563
+ session = self._session
564
+ if (
565
+ self._closed
566
+ or self._revoked
567
+ or self._error is not None
568
+ or self._transport is not transport
569
+ or transport.closed
570
+ or session is None
571
+ or session.status is not SessionStatus.ACTIVATED
572
+ or transport.server_key is None
573
+ or transport.server_key != session.server_public_key
574
+ or transport.identity != session.identity
575
+ ):
576
+ return False
577
+ return True
578
+
579
+ def _receive_call(self, packet: Packet, transport: ZmqTransport) -> None:
580
+ if not self._call_transport_current(transport):
581
+ return
582
+ parsed = incoming_call(packet, self._call_boards)
583
+ if parsed is None:
584
+ return
585
+ notification, token_hash = parsed
586
+ now = monotonic()
587
+ while (
588
+ self._call_tokens
589
+ and next(iter(self._call_tokens.values())) <= now - RECENT_CALL_TTL
590
+ ):
591
+ self._call_tokens.popitem(last=False)
592
+ if token_hash in self._call_tokens:
593
+ self._call_tokens[token_hash] = now
594
+ self._call_tokens.move_to_end(token_hash)
595
+ return
596
+ self._call_tokens[token_hash] = now
597
+ if len(self._call_tokens) > MAX_RECENT_CALLS:
598
+ self._call_tokens.popitem(last=False)
599
+ self._invalidate_auto_hangup()
600
+ for callback in tuple(self._call_listeners):
601
+ try:
602
+ callback(notification)
603
+ except Exception:
604
+ # Callback errors may contain private downstream data.
605
+ continue
606
+ if self._auto_hangup:
607
+ self._schedule_auto_hangup(packet, transport, now)
608
+
609
+ def _schedule_auto_hangup(
610
+ self, packet: Packet, transport: ZmqTransport, received: float
611
+ ) -> None:
612
+ if self._hangup_task is not None and not self._hangup_task.done():
613
+ _LOGGER.debug("IPGUARD auto hangup outcome=skipped")
614
+ return
615
+ body = packet_body(packet)
616
+ token, remote, sender = (
617
+ body.get("token"),
618
+ body.get("remoteIdentity"),
619
+ packet_sender(packet),
620
+ )
621
+ # These fields were validated together with the discarded description.
622
+ assert isinstance(token, str)
623
+ assert isinstance(remote, str)
624
+ assert isinstance(sender, str)
625
+ call = self._hangup_call = _AutomaticHangup(
626
+ transport, token, remote, sender, received + AUTO_HANGUP_TTL
627
+ )
628
+ self._hangup_task = asyncio.create_task(
629
+ self._send_auto_hangup(call), name="ipguard-auto-hangup"
630
+ )
631
+
632
+ async def _send_auto_hangup(self, call: _AutomaticHangup) -> None:
633
+ attempted = False
634
+
635
+ def before_send() -> None:
636
+ nonlocal attempted
637
+ if (
638
+ self._hangup_call is not call
639
+ or not self._connected
640
+ or not self._call_transport_current(call.transport)
641
+ or monotonic() >= call.deadline
642
+ ):
643
+ raise ConnectionFailureError("The incoming call is no longer current.")
644
+ attempted = True
645
+ _LOGGER.debug("IPGUARD auto hangup outcome=attempted")
646
+
647
+ try:
648
+ async with asyncio.timeout(min(self.timeout, AUTO_HANGUP_TTL)):
649
+ await call.transport.async_send(
650
+ hangup_call(call.transport.identity, call.token),
651
+ call.remote,
652
+ before_send=before_send,
653
+ )
654
+ _LOGGER.debug("IPGUARD auto hangup outcome=sent")
655
+ except asyncio.CancelledError:
656
+ _LOGGER.debug(
657
+ "IPGUARD auto hangup outcome=%s",
658
+ "uncertain" if attempted else "skipped",
659
+ )
660
+ raise
661
+ except Exception:
662
+ _LOGGER.debug(
663
+ "IPGUARD auto hangup outcome=%s",
664
+ "uncertain" if attempted else "skipped",
665
+ )
666
+ finally:
667
+ if self._hangup_call is call:
668
+ self._hangup_call = None
669
+
670
+ def _receive_call_termination(
671
+ self, packet: Packet, transport: ZmqTransport
672
+ ) -> None:
673
+ call = self._hangup_call
674
+ if call is None or transport is not call.transport:
675
+ return
676
+ event, body, sender = (
677
+ packet_event(packet),
678
+ packet_body(packet),
679
+ packet_sender(packet),
680
+ )
681
+ expected = {
682
+ "ps_sp_hangup_call": call.sender,
683
+ "brd_sp_hangup_call": call.remote,
684
+ }.get(event)
685
+ if (
686
+ expected is not None
687
+ and sender == expected
688
+ and body.get("token") == call.token
689
+ and ("remoteIdentity" not in body or body["remoteIdentity"] == call.remote)
690
+ ):
691
+ self._invalidate_auto_hangup()
692
+
693
+ def _invalidate_auto_hangup(self) -> None:
694
+ self._hangup_call = None
695
+ if self._hangup_task is not None and not self._hangup_task.done():
696
+ self._hangup_task.cancel()
697
+
698
+ async def _stop_auto_hangup(self) -> None:
699
+ self._invalidate_auto_hangup()
700
+ task, self._hangup_task = self._hangup_task, None
701
+ if task is not None and task is not asyncio.current_task():
702
+ await asyncio.gather(task, return_exceptions=True)
703
+
704
+ async def _on_packet(self, packet: Packet) -> None:
705
+ event, body = packet_event(packet), packet_body(packet)
706
+ _LOGGER.debug(
707
+ "IPGUARD received event %s", event if event in _LOGGED_EVENTS else "other"
708
+ )
709
+ if event in ("srv_sp_delete", "bc_sp_delete_app"):
710
+ if self._session is None or self._session.status is SessionStatus.PENDING:
711
+ return
712
+ self._session = replace(self._session, status=SessionStatus.DISABLED)
713
+ raise AuthRevokedError("IPGUARD revoked this activated installation.")
714
+ if event == "bc_sp_relays":
715
+ boards, self._relays = door_inventory(body)
716
+ unique: dict[str, Board] = {}
717
+ ambiguous: set[str] = set()
718
+ for board in boards:
719
+ if board.identity in unique:
720
+ ambiguous.add(board.identity)
721
+ unique[board.identity] = board
722
+ self._call_boards = {
723
+ identity: board
724
+ for identity, board in unique.items()
725
+ if identity not in ambiguous
726
+ }
727
+ self._inventory_ready.set()
728
+ self._notify()
729
+ if event == "srv_sp_update" and isinstance(body.get("token"), str):
730
+ password = configuration_password(packet)
731
+ if password and self._session:
732
+ self._session = replace(self._session, sip_password=password)
733
+ self._configuration_ready.set()
734
+ assert self._transport is not None
735
+ await self._transport.async_send(
736
+ envelope(self._identity, "sp_srv_update_ack", {}, str(body["token"]))
737
+ )
738
+ self._notify()
739
+
740
+ async def _wait_ready(self, event: asyncio.Event, description: str) -> None:
741
+ ready = asyncio.create_task(event.wait())
742
+ failed = asyncio.create_task(self._failure_ready.wait())
743
+ try:
744
+ async with asyncio.timeout(self.timeout):
745
+ await asyncio.wait((ready, failed), return_when=asyncio.FIRST_COMPLETED)
746
+ except TimeoutError:
747
+ raise ConnectionFailureError(
748
+ f"Timed out waiting for {description}."
749
+ ) from None
750
+ finally:
751
+ for task in (ready, failed):
752
+ task.cancel()
753
+ await asyncio.gather(ready, failed, return_exceptions=True)
754
+ if self._error is not None:
755
+ raise self._error
756
+
757
+ def _on_failure(self, error: IpguardError) -> None:
758
+ self._invalidate_auto_hangup()
759
+ self._connected, self._error = False, error
760
+ self._failure_ready.set()
761
+ self._revoked = isinstance(error, AuthRevokedError)
762
+ _LOGGER.debug("IPGUARD connection stopped: %s", type(error).__name__)
763
+ self._notify()
764
+ if (
765
+ not self._closed
766
+ and not self._revoked
767
+ and self._session
768
+ and self._session.status is SessionStatus.ACTIVATED
769
+ and self._reconnect_task is None
770
+ ):
771
+ self._reconnect_task = asyncio.create_task(
772
+ self._reconnect(), name="ipguard-reconnect"
773
+ )
774
+
775
+ async def _reconnect(self) -> None:
776
+ delay = self._reconnect_delay
777
+ try:
778
+ while not self._closed and not self._revoked:
779
+ _LOGGER.debug("IPGUARD reconnect scheduled")
780
+ await asyncio.sleep(delay)
781
+ try:
782
+ await self._connect_saved()
783
+ return
784
+ except AuthRevokedError:
785
+ return
786
+ except IpguardError as error:
787
+ self._connected, self._error = False, error
788
+ self._notify()
789
+ delay = min(delay * 2, self._max_reconnect_delay)
790
+ finally:
791
+ self._reconnect_task = None
792
+
793
+ async def _stop_connection(self) -> None:
794
+ self._closed = True
795
+ await self._stop_auto_hangup()
796
+ task, self._reconnect_task = self._reconnect_task, None
797
+ if task and task is not asyncio.current_task():
798
+ task.cancel()
799
+ await asyncio.gather(task, return_exceptions=True)
800
+ transport, self._transport = self._transport, None
801
+ if transport:
802
+ await transport.async_close()
803
+ self._connected = False
804
+
805
+ async def async_close(self) -> None:
806
+ await self._stop_connection()
807
+ self._notify()
808
+ self._listeners.clear()
809
+ self._call_listeners.clear()
810
+ self._call_tokens.clear()
811
+ self._call_boards.clear()
812
+ self._verification_requested = False
813
+ self._identifier, self._identity, self._label = None, "", ""
814
+ if self._session and self._session.status is SessionStatus.PENDING:
815
+ self._session = None
816
+
817
+ async def __aenter__(self) -> IpguardClient:
818
+ return self
819
+
820
+ async def __aexit__(
821
+ self,
822
+ exc_type: type[BaseException] | None,
823
+ exc_value: BaseException | None,
824
+ traceback: TracebackType | None,
825
+ ) -> None:
826
+ await self.async_close()
827
+
828
+
829
+ def _valid_curve_key(key: str) -> bool:
830
+ try:
831
+ return len(key) == 40 and len(z85.decode(key.encode("ascii"))) == 32
832
+ except KeyError, ValueError, UnicodeError, StructError, OverflowError:
833
+ return False