adaraz 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.
Potentially problematic release.
This version of adaraz might be problematic. Click here for more details.
- adaraz/__init__.py +99 -0
- adaraz/_channel.py +130 -0
- adaraz/aiohttp_ws.py +80 -0
- adaraz/codes.py +68 -0
- adaraz/const.py +56 -0
- adaraz/device.py +533 -0
- adaraz/exceptions.py +76 -0
- adaraz/messages.py +551 -0
- adaraz/noise.py +464 -0
- adaraz/py.typed +0 -0
- adaraz/server.py +752 -0
- adaraz/storage.py +197 -0
- adaraz/transport.py +118 -0
- adaraz-0.1.0.dist-info/METADATA +300 -0
- adaraz-0.1.0.dist-info/RECORD +17 -0
- adaraz-0.1.0.dist-info/WHEEL +4 -0
- adaraz-0.1.0.dist-info/licenses/LICENSE +21 -0
adaraz/__init__.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""ADARAZ Link: secure connection between ADARAZ devices and servers.
|
|
2
|
+
|
|
3
|
+
See ``docs/protocol.md`` in the repository for the wire protocol.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .codes import derive_psk, format_code, generate_code, normalize_code
|
|
9
|
+
from .const import PROTOCOL_VERSION, RELAY_COMMANDS, SUBPROTOCOL
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
AdarazError,
|
|
12
|
+
ConnectionClosed,
|
|
13
|
+
DeviceNotConnected,
|
|
14
|
+
DeviceNotFound,
|
|
15
|
+
InvalidCodeError,
|
|
16
|
+
PairingCancelled,
|
|
17
|
+
PairingError,
|
|
18
|
+
PairingExpired,
|
|
19
|
+
PairingFailed,
|
|
20
|
+
PairingRejected,
|
|
21
|
+
ProtocolError,
|
|
22
|
+
RelayTimeout,
|
|
23
|
+
)
|
|
24
|
+
from .messages import Cursor, Hello, RelayResult, State
|
|
25
|
+
from .noise import generate_private_key, public_key_from_private
|
|
26
|
+
from .server import (
|
|
27
|
+
DeviceConnected,
|
|
28
|
+
DeviceDisconnected,
|
|
29
|
+
DevicePaired,
|
|
30
|
+
DeviceSession,
|
|
31
|
+
DeviceUnpaired,
|
|
32
|
+
LinkEvent,
|
|
33
|
+
LinkServer,
|
|
34
|
+
PairingWindow,
|
|
35
|
+
PunchReceived,
|
|
36
|
+
StateChanged,
|
|
37
|
+
server_id,
|
|
38
|
+
)
|
|
39
|
+
from .storage import (
|
|
40
|
+
InMemoryStorage,
|
|
41
|
+
LinkStorage,
|
|
42
|
+
PairedDevice,
|
|
43
|
+
PunchDeduplicator,
|
|
44
|
+
PunchKey,
|
|
45
|
+
PunchRecord,
|
|
46
|
+
)
|
|
47
|
+
from .transport import MemoryTransport, Transport, build_ws_url, memory_pipe
|
|
48
|
+
|
|
49
|
+
__version__ = "0.1.0"
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"PROTOCOL_VERSION",
|
|
53
|
+
"RELAY_COMMANDS",
|
|
54
|
+
"SUBPROTOCOL",
|
|
55
|
+
"AdarazError",
|
|
56
|
+
"ConnectionClosed",
|
|
57
|
+
"Cursor",
|
|
58
|
+
"DeviceConnected",
|
|
59
|
+
"DeviceDisconnected",
|
|
60
|
+
"DeviceNotConnected",
|
|
61
|
+
"DeviceNotFound",
|
|
62
|
+
"DevicePaired",
|
|
63
|
+
"DeviceSession",
|
|
64
|
+
"DeviceUnpaired",
|
|
65
|
+
"Hello",
|
|
66
|
+
"InMemoryStorage",
|
|
67
|
+
"InvalidCodeError",
|
|
68
|
+
"LinkEvent",
|
|
69
|
+
"LinkServer",
|
|
70
|
+
"LinkStorage",
|
|
71
|
+
"MemoryTransport",
|
|
72
|
+
"PairedDevice",
|
|
73
|
+
"PairingCancelled",
|
|
74
|
+
"PairingError",
|
|
75
|
+
"PairingExpired",
|
|
76
|
+
"PairingFailed",
|
|
77
|
+
"PairingRejected",
|
|
78
|
+
"PairingWindow",
|
|
79
|
+
"ProtocolError",
|
|
80
|
+
"PunchDeduplicator",
|
|
81
|
+
"PunchKey",
|
|
82
|
+
"PunchReceived",
|
|
83
|
+
"PunchRecord",
|
|
84
|
+
"RelayResult",
|
|
85
|
+
"RelayTimeout",
|
|
86
|
+
"State",
|
|
87
|
+
"StateChanged",
|
|
88
|
+
"Transport",
|
|
89
|
+
"__version__",
|
|
90
|
+
"build_ws_url",
|
|
91
|
+
"derive_psk",
|
|
92
|
+
"format_code",
|
|
93
|
+
"generate_code",
|
|
94
|
+
"generate_private_key",
|
|
95
|
+
"memory_pipe",
|
|
96
|
+
"normalize_code",
|
|
97
|
+
"public_key_from_private",
|
|
98
|
+
"server_id",
|
|
99
|
+
]
|
adaraz/_channel.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Encrypted message channel shared by the server and the device implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import itertools
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
from .const import CLOSE_GOING_AWAY, MAX_NOISE_MESSAGE
|
|
11
|
+
from .exceptions import ConnectionClosed, ProtocolError
|
|
12
|
+
from .messages import Message, Ping, Pong, UnknownMessage, decode, encode
|
|
13
|
+
from .noise import CipherState, NoiseError
|
|
14
|
+
from .transport import Transport
|
|
15
|
+
|
|
16
|
+
_LOGGER = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SecureChannel:
|
|
20
|
+
"""Noise transport messages over a :class:`Transport`.
|
|
21
|
+
|
|
22
|
+
* serialises sends (the Noise nonce must match the order on the wire),
|
|
23
|
+
* answers ``ping`` with ``pong`` and swallows ``pong`` and unknown types,
|
|
24
|
+
* rejects known types that are not valid in this direction (close 4000),
|
|
25
|
+
* implements liveness (:meth:`run_liveness`).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
transport: Transport,
|
|
31
|
+
send_cipher: CipherState,
|
|
32
|
+
recv_cipher: CipherState,
|
|
33
|
+
*,
|
|
34
|
+
accept: frozenset[type[Message]],
|
|
35
|
+
ping_interval: float,
|
|
36
|
+
idle_timeout: float,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.transport = transport
|
|
39
|
+
self._send_cipher = send_cipher
|
|
40
|
+
self._recv_cipher = recv_cipher
|
|
41
|
+
self._accept = accept
|
|
42
|
+
self._ping_interval = ping_interval
|
|
43
|
+
self._idle_timeout = idle_timeout
|
|
44
|
+
self._lock = asyncio.Lock()
|
|
45
|
+
loop = asyncio.get_running_loop()
|
|
46
|
+
self._time = loop.time
|
|
47
|
+
self._last_send = self._last_recv = self._time()
|
|
48
|
+
self._ping_ids = itertools.count(1)
|
|
49
|
+
self.close_code: int | None = None
|
|
50
|
+
|
|
51
|
+
async def send(self, message: Message) -> None:
|
|
52
|
+
payload = encode(message)
|
|
53
|
+
async with self._lock:
|
|
54
|
+
if self.close_code is not None:
|
|
55
|
+
raise ConnectionClosed("channel closed")
|
|
56
|
+
data = self._send_cipher.encrypt_with_ad(b"", payload)
|
|
57
|
+
self._last_send = self._time()
|
|
58
|
+
try:
|
|
59
|
+
await self.transport.send(data)
|
|
60
|
+
except ConnectionClosed:
|
|
61
|
+
raise
|
|
62
|
+
except Exception as err:
|
|
63
|
+
raise ConnectionClosed(str(err)) from err
|
|
64
|
+
|
|
65
|
+
async def recv(self) -> Message | None:
|
|
66
|
+
"""Return the next application message, or ``None`` when the connection ends."""
|
|
67
|
+
while True:
|
|
68
|
+
message = await self.recv_any()
|
|
69
|
+
if message is None:
|
|
70
|
+
return None
|
|
71
|
+
if await self.filter(message):
|
|
72
|
+
return message
|
|
73
|
+
|
|
74
|
+
async def recv_any(self) -> Message | None:
|
|
75
|
+
"""Receive, decrypt and decode the next transport message, whatever its type.
|
|
76
|
+
|
|
77
|
+
Returns ``None`` when the connection ends. Nothing is answered or filtered;
|
|
78
|
+
pass the message to :meth:`filter` before using it.
|
|
79
|
+
"""
|
|
80
|
+
data = await self.transport.recv()
|
|
81
|
+
if data is None:
|
|
82
|
+
return None
|
|
83
|
+
self._last_recv = self._time()
|
|
84
|
+
if len(data) > MAX_NOISE_MESSAGE:
|
|
85
|
+
raise ProtocolError("message too large")
|
|
86
|
+
try:
|
|
87
|
+
payload = self._recv_cipher.decrypt_with_ad(b"", data)
|
|
88
|
+
except NoiseError as err:
|
|
89
|
+
raise ProtocolError(f"cannot decrypt transport message: {err}") from err
|
|
90
|
+
return decode(payload)
|
|
91
|
+
|
|
92
|
+
async def filter(self, message: Message) -> bool:
|
|
93
|
+
"""Handle the channel level part of a message; True if it is for the application.
|
|
94
|
+
|
|
95
|
+
``ping`` is answered with ``pong``; ``pong`` and unknown types are swallowed;
|
|
96
|
+
a known type not valid in this direction raises :class:`ProtocolError`.
|
|
97
|
+
"""
|
|
98
|
+
if isinstance(message, Ping):
|
|
99
|
+
# If the connection is gone, the next recv returns None.
|
|
100
|
+
with contextlib.suppress(ConnectionClosed):
|
|
101
|
+
await self.send(Pong(id=message.id))
|
|
102
|
+
return False
|
|
103
|
+
if isinstance(message, (Pong, UnknownMessage)):
|
|
104
|
+
return False
|
|
105
|
+
if type(message) not in self._accept:
|
|
106
|
+
raise ProtocolError(f"unexpected message type {message.T!r}")
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
async def close(self, code: int) -> None:
|
|
110
|
+
if self.close_code is None:
|
|
111
|
+
self.close_code = code
|
|
112
|
+
await self.transport.close(code)
|
|
113
|
+
|
|
114
|
+
async def run_liveness(self) -> None:
|
|
115
|
+
"""Send ``ping`` when idle and close the connection when the peer is silent."""
|
|
116
|
+
while self.close_code is None:
|
|
117
|
+
now = self._time()
|
|
118
|
+
idle_deadline = self._last_recv + self._idle_timeout
|
|
119
|
+
ping_deadline = self._last_send + self._ping_interval
|
|
120
|
+
if now >= idle_deadline:
|
|
121
|
+
_LOGGER.debug("peer silent for %.0f s, closing", self._idle_timeout)
|
|
122
|
+
await self.close(CLOSE_GOING_AWAY)
|
|
123
|
+
return
|
|
124
|
+
if now >= ping_deadline:
|
|
125
|
+
try:
|
|
126
|
+
await self.send(Ping(id=next(self._ping_ids)))
|
|
127
|
+
except ConnectionClosed:
|
|
128
|
+
return
|
|
129
|
+
continue
|
|
130
|
+
await asyncio.sleep(min(idle_deadline, ping_deadline) - now)
|
adaraz/aiohttp_ws.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""aiohttp adapter (server and client side). Requires the ``aiohttp`` extra."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
from aiohttp import WSMsgType, web
|
|
10
|
+
|
|
11
|
+
from .const import CLOSE_NORMAL, CLOSE_PROTOCOL_ERROR, MAX_WS_MESSAGE, SUBPROTOCOL
|
|
12
|
+
from .exceptions import ConnectionClosed, ProtocolError
|
|
13
|
+
from .transport import build_ws_url
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .server import LinkServer
|
|
17
|
+
|
|
18
|
+
__all__ = ["AiohttpTransport", "connect", "handle_request"]
|
|
19
|
+
|
|
20
|
+
_CLOSED = (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED, WSMsgType.ERROR)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AiohttpTransport:
|
|
24
|
+
""":class:`adaraz.Transport` over an aiohttp WebSocket (either side)."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, ws: web.WebSocketResponse | aiohttp.ClientWebSocketResponse) -> None:
|
|
27
|
+
self.ws = ws
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def close_code(self) -> int | None:
|
|
31
|
+
return self.ws.close_code
|
|
32
|
+
|
|
33
|
+
async def recv(self) -> bytes | None:
|
|
34
|
+
if self.ws.closed:
|
|
35
|
+
return None
|
|
36
|
+
msg = await self.ws.receive()
|
|
37
|
+
if msg.type == WSMsgType.BINARY:
|
|
38
|
+
data: bytes = msg.data
|
|
39
|
+
return data
|
|
40
|
+
if msg.type == WSMsgType.TEXT:
|
|
41
|
+
raise ProtocolError("text WebSocket message", CLOSE_PROTOCOL_ERROR)
|
|
42
|
+
if msg.type in _CLOSED:
|
|
43
|
+
return None
|
|
44
|
+
raise ProtocolError(f"unexpected WebSocket message type {msg.type!r}")
|
|
45
|
+
|
|
46
|
+
async def send(self, data: bytes) -> None:
|
|
47
|
+
if self.ws.closed:
|
|
48
|
+
raise ConnectionClosed("WebSocket closed")
|
|
49
|
+
try:
|
|
50
|
+
await self.ws.send_bytes(data)
|
|
51
|
+
except (ConnectionError, RuntimeError, aiohttp.ClientError) as err:
|
|
52
|
+
raise ConnectionClosed(str(err)) from err
|
|
53
|
+
|
|
54
|
+
async def close(self, code: int = CLOSE_NORMAL) -> None:
|
|
55
|
+
if self.ws.closed:
|
|
56
|
+
return
|
|
57
|
+
with contextlib.suppress(ConnectionError, RuntimeError, aiohttp.ClientError):
|
|
58
|
+
await self.ws.close(code=code)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def handle_request(server: LinkServer, request: web.Request) -> web.WebSocketResponse:
|
|
62
|
+
"""Accept a WebSocket request and run it as an ADARAZ Link connection."""
|
|
63
|
+
ws = web.WebSocketResponse(protocols=(SUBPROTOCOL,), max_msg_size=MAX_WS_MESSAGE, autoping=True)
|
|
64
|
+
await ws.prepare(request)
|
|
65
|
+
await server.handle_connection(AiohttpTransport(ws))
|
|
66
|
+
return ws
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def connect(
|
|
70
|
+
session: aiohttp.ClientSession, base_url: str, *, timeout: float = 10.0
|
|
71
|
+
) -> AiohttpTransport:
|
|
72
|
+
"""Open a client WebSocket to a server base URL (device side, for tests/tools)."""
|
|
73
|
+
ws = await session.ws_connect(
|
|
74
|
+
build_ws_url(base_url),
|
|
75
|
+
protocols=(SUBPROTOCOL,),
|
|
76
|
+
max_msg_size=MAX_WS_MESSAGE,
|
|
77
|
+
autoping=True,
|
|
78
|
+
timeout=aiohttp.ClientWSTimeout(ws_receive=None, ws_close=timeout),
|
|
79
|
+
)
|
|
80
|
+
return AiohttpTransport(ws)
|
adaraz/codes.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Pairing codes and PSK derivation (protocol section 2, "Pairing code and PSK")."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
|
|
7
|
+
from cryptography.hazmat.primitives import hashes
|
|
8
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
9
|
+
|
|
10
|
+
from .exceptions import InvalidCodeError
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ALPHABET",
|
|
14
|
+
"CODE_LENGTH",
|
|
15
|
+
"GROUP_LENGTH",
|
|
16
|
+
"PSK_INFO",
|
|
17
|
+
"PSK_SALT",
|
|
18
|
+
"derive_psk",
|
|
19
|
+
"format_code",
|
|
20
|
+
"generate_code",
|
|
21
|
+
"normalize_code",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
#: Crockford Base32 alphabet.
|
|
25
|
+
ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
26
|
+
#: Number of symbols in a pairing code (60 bits).
|
|
27
|
+
CODE_LENGTH = 12
|
|
28
|
+
#: Symbols per group in the display form ``XXXX-XXXX-XXXX``.
|
|
29
|
+
GROUP_LENGTH = 4
|
|
30
|
+
PSK_SALT = b"ADARAZ pairing v1"
|
|
31
|
+
PSK_INFO = b"psk"
|
|
32
|
+
|
|
33
|
+
_MAP = str.maketrans({"O": "0", "I": "1", "L": "1"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def generate_code() -> str:
|
|
37
|
+
"""Return a new random pairing code formatted as ``XXXX-XXXX-XXXX``."""
|
|
38
|
+
raw = "".join(secrets.choice(ALPHABET) for _ in range(CODE_LENGTH))
|
|
39
|
+
return format_code(raw)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize_code(code: str) -> str:
|
|
43
|
+
"""Normalise a code as typed by a person.
|
|
44
|
+
|
|
45
|
+
Uppercase, remove ``-`` and spaces, map ``O`` to ``0`` and ``I``/``L`` to ``1``.
|
|
46
|
+
Raises :class:`InvalidCodeError` if the result is not 12 alphabet symbols.
|
|
47
|
+
"""
|
|
48
|
+
if not code.isascii():
|
|
49
|
+
raise InvalidCodeError("pairing code contains non-ASCII characters")
|
|
50
|
+
norm = code.upper().replace("-", "").replace(" ", "").translate(_MAP)
|
|
51
|
+
if len(norm) != CODE_LENGTH or any(ch not in ALPHABET for ch in norm):
|
|
52
|
+
raise InvalidCodeError(
|
|
53
|
+
f"pairing code must be {CODE_LENGTH} symbols of 0-9 and A-Z (without U)"
|
|
54
|
+
)
|
|
55
|
+
return norm
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def format_code(code: str) -> str:
|
|
59
|
+
"""Return the display form ``XXXX-XXXX-XXXX`` of a code (normalised first)."""
|
|
60
|
+
norm = normalize_code(code)
|
|
61
|
+
return "-".join(norm[i : i + GROUP_LENGTH] for i in range(0, CODE_LENGTH, GROUP_LENGTH))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def derive_psk(code: str) -> bytes:
|
|
65
|
+
"""Derive the 32 byte Noise PSK from a pairing code (normalised first)."""
|
|
66
|
+
norm = normalize_code(code)
|
|
67
|
+
hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=PSK_SALT, info=PSK_INFO)
|
|
68
|
+
return hkdf.derive(norm.encode("ascii"))
|
adaraz/const.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Protocol constants (ADARAZ Link protocol, version 1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
PROTOCOL_VERSION: Final = 1
|
|
8
|
+
SUBPROTOCOL: Final = "adaraz.v1"
|
|
9
|
+
DEFAULT_PATH: Final = "/api/adaraz/ws"
|
|
10
|
+
|
|
11
|
+
MODE_PAIR: Final = 0x50 # "P"
|
|
12
|
+
MODE_SESSION: Final = 0x53 # "S"
|
|
13
|
+
|
|
14
|
+
PROLOGUE_PAIR: Final = b"ADARAZ/1 pair"
|
|
15
|
+
PROLOGUE_SESSION: Final = b"ADARAZ/1 session"
|
|
16
|
+
|
|
17
|
+
#: Largest Noise message (payload plus 16 byte tag).
|
|
18
|
+
MAX_NOISE_MESSAGE: Final = 4096
|
|
19
|
+
#: Largest JSON payload of a transport message.
|
|
20
|
+
MAX_PAYLOAD: Final = MAX_NOISE_MESSAGE - 16
|
|
21
|
+
#: Largest WebSocket message accepted at the WebSocket layer. Anything above
|
|
22
|
+
#: MAX_NOISE_MESSAGE (+1 for the mode byte) and below this is closed with 4000 by the
|
|
23
|
+
#: protocol code; anything above this is closed by the WebSocket library.
|
|
24
|
+
MAX_WS_MESSAGE: Final = 65536
|
|
25
|
+
|
|
26
|
+
MAX_PUNCH_ITEMS: Final = 100
|
|
27
|
+
#: Largest log index, count, cursor position and punch timestamp (uint32).
|
|
28
|
+
MAX_UINT32: Final = 0xFFFFFFFF
|
|
29
|
+
|
|
30
|
+
CLOSE_NORMAL: Final = 1000
|
|
31
|
+
CLOSE_GOING_AWAY: Final = 1001
|
|
32
|
+
CLOSE_INTERNAL_ERROR: Final = 1011
|
|
33
|
+
CLOSE_PROTOCOL_ERROR: Final = 4000
|
|
34
|
+
CLOSE_UNKNOWN_DEVICE: Final = 4001
|
|
35
|
+
CLOSE_SUPERSEDED: Final = 4002
|
|
36
|
+
CLOSE_PAIRING_REJECTED: Final = 4003
|
|
37
|
+
|
|
38
|
+
PING_INTERVAL: Final = 30.0
|
|
39
|
+
IDLE_TIMEOUT: Final = 90.0
|
|
40
|
+
#: A session that is not established (the server has not decrypted the device's first
|
|
41
|
+
#: transport message) within this time is closed (protocol section 4).
|
|
42
|
+
ESTABLISH_TIMEOUT: Final = 10.0
|
|
43
|
+
#: Time the server has to send ``paired`` after pairing message 3.
|
|
44
|
+
PAIRED_DEADLINE: Final = 10.0
|
|
45
|
+
#: Pairing window lifetime and attempt limit.
|
|
46
|
+
PAIRING_WINDOW: Final = 600.0
|
|
47
|
+
PAIRING_MAX_FAILURES: Final = 5
|
|
48
|
+
#: Punches older than this at arrival are "late".
|
|
49
|
+
LATE_AFTER: Final = 300.0
|
|
50
|
+
|
|
51
|
+
RELAY_COMMANDS: Final = ("open", "on", "off", "pulse")
|
|
52
|
+
PULSE_MS_MIN: Final = 100
|
|
53
|
+
PULSE_MS_MAX: Final = 10000
|
|
54
|
+
|
|
55
|
+
CAP_PUNCHES: Final = "punches"
|
|
56
|
+
CAP_RELAY: Final = "relay"
|