pyremootio 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.
- pyremootio/__init__.py +47 -0
- pyremootio/client.py +691 -0
- pyremootio/const.py +15 -0
- pyremootio/crypto.py +169 -0
- pyremootio/exceptions.py +37 -0
- pyremootio/models.py +244 -0
- pyremootio/py.typed +0 -0
- pyremootio-0.1.0.dist-info/METADATA +134 -0
- pyremootio-0.1.0.dist-info/RECORD +11 -0
- pyremootio-0.1.0.dist-info/WHEEL +4 -0
- pyremootio-0.1.0.dist-info/licenses/LICENSE +21 -0
pyremootio/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Async Python client for the Remootio Websocket API."""
|
|
2
|
+
|
|
3
|
+
from pyremootio.client import RemootioClient
|
|
4
|
+
from pyremootio.exceptions import (
|
|
5
|
+
RemootioActionError,
|
|
6
|
+
RemootioAuthenticationError,
|
|
7
|
+
RemootioConnectionError,
|
|
8
|
+
RemootioCryptoError,
|
|
9
|
+
RemootioError,
|
|
10
|
+
RemootioTimeoutError,
|
|
11
|
+
)
|
|
12
|
+
from pyremootio.models import (
|
|
13
|
+
ActionErrorCode,
|
|
14
|
+
ActionResponse,
|
|
15
|
+
ActionType,
|
|
16
|
+
ConnectionVia,
|
|
17
|
+
Credentials,
|
|
18
|
+
DeviceErrorMessage,
|
|
19
|
+
DoorState,
|
|
20
|
+
EventType,
|
|
21
|
+
KeyType,
|
|
22
|
+
RemootioEvent,
|
|
23
|
+
ServerHello,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"ActionErrorCode",
|
|
28
|
+
"ActionResponse",
|
|
29
|
+
"ActionType",
|
|
30
|
+
"ConnectionVia",
|
|
31
|
+
"Credentials",
|
|
32
|
+
"DeviceErrorMessage",
|
|
33
|
+
"DoorState",
|
|
34
|
+
"EventType",
|
|
35
|
+
"KeyType",
|
|
36
|
+
"RemootioActionError",
|
|
37
|
+
"RemootioAuthenticationError",
|
|
38
|
+
"RemootioClient",
|
|
39
|
+
"RemootioConnectionError",
|
|
40
|
+
"RemootioCryptoError",
|
|
41
|
+
"RemootioError",
|
|
42
|
+
"RemootioEvent",
|
|
43
|
+
"RemootioTimeoutError",
|
|
44
|
+
"ServerHello",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
__version__ = "0.1.0"
|
pyremootio/client.py
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
"""Async Remootio Websocket API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from inspect import isawaitable
|
|
11
|
+
from typing import Any, Self
|
|
12
|
+
|
|
13
|
+
import aiohttp
|
|
14
|
+
|
|
15
|
+
from pyremootio.const import (
|
|
16
|
+
ACTION_ID_MASK,
|
|
17
|
+
DEFAULT_ACTION_TIMEOUT,
|
|
18
|
+
DEFAULT_AUTH_TIMEOUT,
|
|
19
|
+
DEFAULT_HELLO_TIMEOUT,
|
|
20
|
+
DEFAULT_PING_INTERVAL,
|
|
21
|
+
DEFAULT_PORT,
|
|
22
|
+
INITIAL_RECONNECT_DELAY,
|
|
23
|
+
MAX_RECONNECT_DELAY,
|
|
24
|
+
MIN_API_VERSION_DURATION,
|
|
25
|
+
)
|
|
26
|
+
from pyremootio.crypto import compact_json, decrypt_frame, encrypt_payload
|
|
27
|
+
from pyremootio.exceptions import (
|
|
28
|
+
RemootioActionError,
|
|
29
|
+
RemootioAuthenticationError,
|
|
30
|
+
RemootioConnectionError,
|
|
31
|
+
RemootioCryptoError,
|
|
32
|
+
RemootioError,
|
|
33
|
+
RemootioTimeoutError,
|
|
34
|
+
)
|
|
35
|
+
from pyremootio.models import (
|
|
36
|
+
ActionResponse,
|
|
37
|
+
ActionType,
|
|
38
|
+
Challenge,
|
|
39
|
+
Credentials,
|
|
40
|
+
DoorState,
|
|
41
|
+
RemootioEvent,
|
|
42
|
+
ServerHello,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_LOGGER = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
EventCallback = Callable[[RemootioEvent], Awaitable[None] | None]
|
|
48
|
+
ConnectionCallback = Callable[[bool], Awaitable[None] | None]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RemootioClient:
|
|
52
|
+
"""Async client for one Remootio device."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
host: str,
|
|
57
|
+
secret_key: str,
|
|
58
|
+
auth_key: str,
|
|
59
|
+
session: aiohttp.ClientSession,
|
|
60
|
+
*,
|
|
61
|
+
port: int = DEFAULT_PORT,
|
|
62
|
+
ping_interval: float = DEFAULT_PING_INTERVAL,
|
|
63
|
+
action_timeout: float = DEFAULT_ACTION_TIMEOUT,
|
|
64
|
+
auth_timeout: float = DEFAULT_AUTH_TIMEOUT,
|
|
65
|
+
) -> None:
|
|
66
|
+
if ping_interval <= 0:
|
|
67
|
+
raise ValueError("ping_interval must be positive")
|
|
68
|
+
self._host = host
|
|
69
|
+
self._port = port
|
|
70
|
+
self._credentials = Credentials(secret_key=secret_key, auth_key=auth_key)
|
|
71
|
+
self._session = session
|
|
72
|
+
self._ping_interval = ping_interval
|
|
73
|
+
self._ping_timeout = ping_interval / 2
|
|
74
|
+
self._action_timeout = action_timeout
|
|
75
|
+
self._auth_timeout = auth_timeout
|
|
76
|
+
|
|
77
|
+
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
|
78
|
+
self._session_key: str | None = None
|
|
79
|
+
self._last_action_id: int | None = None
|
|
80
|
+
self._authenticated = False
|
|
81
|
+
self._state = DoorState.UNKNOWN
|
|
82
|
+
self._server_hello: ServerHello | None = None
|
|
83
|
+
|
|
84
|
+
self._receive_task: asyncio.Task[None] | None = None
|
|
85
|
+
self._ping_task: asyncio.Task[None] | None = None
|
|
86
|
+
self._ping_watchdog: asyncio.Task[None] | None = None
|
|
87
|
+
self._reconnect_task: asyncio.Task[None] | None = None
|
|
88
|
+
|
|
89
|
+
self._reconnect = False
|
|
90
|
+
self._closed_by_user = False
|
|
91
|
+
self._shutting_down = False
|
|
92
|
+
self._action_lock = asyncio.Lock()
|
|
93
|
+
self._pending: dict[int, asyncio.Future[ActionResponse]] = {}
|
|
94
|
+
self._authenticated_event = asyncio.Event()
|
|
95
|
+
self._hello_event = asyncio.Event()
|
|
96
|
+
self._auth_error: BaseException | None = None
|
|
97
|
+
self._disconnect_reason: str | None = None
|
|
98
|
+
self._listeners: list[EventCallback] = []
|
|
99
|
+
self._connection_listeners: list[ConnectionCallback] = []
|
|
100
|
+
self._callback_tasks: set[asyncio.Task[None]] = set()
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def from_credentials(
|
|
104
|
+
cls,
|
|
105
|
+
host: str,
|
|
106
|
+
credentials: Credentials,
|
|
107
|
+
session: aiohttp.ClientSession,
|
|
108
|
+
*,
|
|
109
|
+
port: int = DEFAULT_PORT,
|
|
110
|
+
ping_interval: float = DEFAULT_PING_INTERVAL,
|
|
111
|
+
action_timeout: float = DEFAULT_ACTION_TIMEOUT,
|
|
112
|
+
auth_timeout: float = DEFAULT_AUTH_TIMEOUT,
|
|
113
|
+
) -> RemootioClient:
|
|
114
|
+
return cls(
|
|
115
|
+
host,
|
|
116
|
+
credentials.secret_key,
|
|
117
|
+
credentials.auth_key,
|
|
118
|
+
session,
|
|
119
|
+
port=port,
|
|
120
|
+
ping_interval=ping_interval,
|
|
121
|
+
action_timeout=action_timeout,
|
|
122
|
+
auth_timeout=auth_timeout,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def host(self) -> str:
|
|
127
|
+
return self._host
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def port(self) -> int:
|
|
131
|
+
return self._port
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def credentials(self) -> Credentials:
|
|
135
|
+
return self._credentials
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def state(self) -> DoorState:
|
|
139
|
+
return self._state
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def serial_number(self) -> str | None:
|
|
143
|
+
if self._server_hello is None:
|
|
144
|
+
return None
|
|
145
|
+
return self._server_hello.serial_number
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def api_version(self) -> int | None:
|
|
149
|
+
if self._server_hello is None:
|
|
150
|
+
return None
|
|
151
|
+
return self._server_hello.api_version
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def remootio_version(self) -> str | None:
|
|
155
|
+
if self._server_hello is None:
|
|
156
|
+
return None
|
|
157
|
+
return self._server_hello.remootio_version
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def connected(self) -> bool:
|
|
161
|
+
return self._ws is not None and not self._ws.closed
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def authenticated(self) -> bool:
|
|
165
|
+
return self.connected and self._authenticated and self._session_key is not None
|
|
166
|
+
|
|
167
|
+
def __repr__(self) -> str:
|
|
168
|
+
return (
|
|
169
|
+
f"RemootioClient(host={self._host!r}, port={self._port}, "
|
|
170
|
+
f"authenticated={self.authenticated})"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def listen(self, callback: EventCallback) -> Callable[[], None]:
|
|
174
|
+
"""Subscribe to decrypted device events. Returns an unsubscribe callback."""
|
|
175
|
+
self._listeners.append(callback)
|
|
176
|
+
|
|
177
|
+
def _unsubscribe() -> None:
|
|
178
|
+
with suppress(ValueError):
|
|
179
|
+
self._listeners.remove(callback)
|
|
180
|
+
|
|
181
|
+
return _unsubscribe
|
|
182
|
+
|
|
183
|
+
def listen_connection(self, callback: ConnectionCallback) -> Callable[[], None]:
|
|
184
|
+
"""Subscribe to connection availability changes (True = authenticated)."""
|
|
185
|
+
self._connection_listeners.append(callback)
|
|
186
|
+
|
|
187
|
+
def _unsubscribe() -> None:
|
|
188
|
+
with suppress(ValueError):
|
|
189
|
+
self._connection_listeners.remove(callback)
|
|
190
|
+
|
|
191
|
+
return _unsubscribe
|
|
192
|
+
|
|
193
|
+
async def connect(self, *, reconnect: bool = False) -> None:
|
|
194
|
+
"""Open the websocket, authenticate, and optionally keep reconnecting."""
|
|
195
|
+
self._reconnect = reconnect
|
|
196
|
+
self._closed_by_user = False
|
|
197
|
+
await self._establish()
|
|
198
|
+
if reconnect and (self._reconnect_task is None or self._reconnect_task.done()):
|
|
199
|
+
self._reconnect_task = asyncio.create_task(
|
|
200
|
+
self._reconnect_loop(),
|
|
201
|
+
name="pyremootio-reconnect",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
async def disconnect(self) -> None:
|
|
205
|
+
"""Close the session and disable automatic reconnect."""
|
|
206
|
+
self._closed_by_user = True
|
|
207
|
+
self._reconnect = False
|
|
208
|
+
self._set_disconnect_reason("client requested disconnect")
|
|
209
|
+
if self._reconnect_task is not None:
|
|
210
|
+
self._reconnect_task.cancel()
|
|
211
|
+
with suppress(asyncio.CancelledError):
|
|
212
|
+
await self._reconnect_task
|
|
213
|
+
self._reconnect_task = None
|
|
214
|
+
await self._shutdown()
|
|
215
|
+
|
|
216
|
+
async def __aenter__(self) -> Self:
|
|
217
|
+
await self.connect()
|
|
218
|
+
return self
|
|
219
|
+
|
|
220
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
221
|
+
await self.disconnect()
|
|
222
|
+
|
|
223
|
+
async def query(self) -> ActionResponse:
|
|
224
|
+
return await self._request_action(ActionType.QUERY)
|
|
225
|
+
|
|
226
|
+
async def trigger(self, duration_minutes: int | None = None) -> ActionResponse:
|
|
227
|
+
return await self._request_action(ActionType.TRIGGER, duration_minutes)
|
|
228
|
+
|
|
229
|
+
async def trigger_secondary(self, duration_minutes: int | None = None) -> ActionResponse:
|
|
230
|
+
return await self._request_action(ActionType.TRIGGER_SECONDARY, duration_minutes)
|
|
231
|
+
|
|
232
|
+
async def open(self, duration_minutes: int | None = None) -> ActionResponse:
|
|
233
|
+
return await self._request_action(ActionType.OPEN, duration_minutes)
|
|
234
|
+
|
|
235
|
+
async def close(self, duration_minutes: int | None = None) -> ActionResponse:
|
|
236
|
+
return await self._request_action(ActionType.CLOSE, duration_minutes)
|
|
237
|
+
|
|
238
|
+
async def restart(self) -> ActionResponse:
|
|
239
|
+
return await self._request_action(ActionType.RESTART)
|
|
240
|
+
|
|
241
|
+
async def _establish(self) -> None:
|
|
242
|
+
if self.connected:
|
|
243
|
+
self._set_disconnect_reason("replaced by new connection")
|
|
244
|
+
await self._shutdown(notify=False)
|
|
245
|
+
|
|
246
|
+
self._reset_session()
|
|
247
|
+
url = f"ws://{self._host}:{self._port}/"
|
|
248
|
+
_LOGGER.debug("Connecting to %s", url)
|
|
249
|
+
try:
|
|
250
|
+
self._ws = await self._session.ws_connect(
|
|
251
|
+
url,
|
|
252
|
+
heartbeat=None,
|
|
253
|
+
autoping=False,
|
|
254
|
+
compress=0,
|
|
255
|
+
)
|
|
256
|
+
except (aiohttp.ClientError, OSError) as err:
|
|
257
|
+
raise RemootioConnectionError(f"Could not connect to {url}") from err
|
|
258
|
+
|
|
259
|
+
_LOGGER.info("Connected to Remootio websocket (%s:%s)", self._host, self._port)
|
|
260
|
+
|
|
261
|
+
self._receive_task = asyncio.create_task(
|
|
262
|
+
self._receive_loop(),
|
|
263
|
+
name="pyremootio-receive",
|
|
264
|
+
)
|
|
265
|
+
self._ping_task = asyncio.create_task(
|
|
266
|
+
self._ping_loop(),
|
|
267
|
+
name="pyremootio-ping",
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
await self._send_basic({"type": "HELLO"})
|
|
271
|
+
# The device parses only one websocket frame per recv(). Sending AUTH in
|
|
272
|
+
# the same TCP packet as HELLO would drop AUTH and stall authentication.
|
|
273
|
+
with suppress(TimeoutError):
|
|
274
|
+
await asyncio.wait_for(self._hello_event.wait(), timeout=DEFAULT_HELLO_TIMEOUT)
|
|
275
|
+
await self._send_basic({"type": "AUTH"})
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
await asyncio.wait_for(self._authenticated_event.wait(), timeout=self._auth_timeout)
|
|
279
|
+
except TimeoutError as err:
|
|
280
|
+
self._set_disconnect_reason("authentication timed out")
|
|
281
|
+
await self._shutdown(notify=False)
|
|
282
|
+
raise RemootioAuthenticationError("Authentication timed out") from err
|
|
283
|
+
|
|
284
|
+
if self._auth_error is not None:
|
|
285
|
+
error = self._auth_error
|
|
286
|
+
self._set_disconnect_reason(str(error))
|
|
287
|
+
await self._shutdown(notify=False)
|
|
288
|
+
raise error
|
|
289
|
+
|
|
290
|
+
if not self._authenticated:
|
|
291
|
+
self._set_disconnect_reason("authentication failed")
|
|
292
|
+
await self._shutdown(notify=False)
|
|
293
|
+
raise RemootioAuthenticationError("Authentication failed")
|
|
294
|
+
|
|
295
|
+
_LOGGER.info(
|
|
296
|
+
"Authenticated with Remootio (%s:%s serial=%s api=%s state=%s)",
|
|
297
|
+
self._host,
|
|
298
|
+
self._port,
|
|
299
|
+
self.serial_number,
|
|
300
|
+
self.api_version,
|
|
301
|
+
self._state,
|
|
302
|
+
)
|
|
303
|
+
self._notify_connection(True)
|
|
304
|
+
|
|
305
|
+
async def _reconnect_loop(self) -> None:
|
|
306
|
+
delay = INITIAL_RECONNECT_DELAY
|
|
307
|
+
while self._reconnect and not self._closed_by_user:
|
|
308
|
+
if self.authenticated:
|
|
309
|
+
if self._receive_task is None:
|
|
310
|
+
return
|
|
311
|
+
with suppress(asyncio.CancelledError):
|
|
312
|
+
await self._receive_task
|
|
313
|
+
if self._closed_by_user or not self._reconnect:
|
|
314
|
+
return
|
|
315
|
+
_LOGGER.warning(
|
|
316
|
+
"Remootio connection lost (%s:%s): %s; reconnecting in %.1fs",
|
|
317
|
+
self._host,
|
|
318
|
+
self._port,
|
|
319
|
+
self._disconnect_reason or "unknown",
|
|
320
|
+
delay,
|
|
321
|
+
)
|
|
322
|
+
await asyncio.sleep(delay)
|
|
323
|
+
if self._closed_by_user or not self._reconnect:
|
|
324
|
+
return
|
|
325
|
+
try:
|
|
326
|
+
await self._establish()
|
|
327
|
+
delay = INITIAL_RECONNECT_DELAY
|
|
328
|
+
except RemootioError as err:
|
|
329
|
+
_LOGGER.warning("Reconnect failed (%s:%s): %s", self._host, self._port, err)
|
|
330
|
+
delay = min(delay * 2, MAX_RECONNECT_DELAY)
|
|
331
|
+
|
|
332
|
+
def _reset_session(self) -> None:
|
|
333
|
+
self._session_key = None
|
|
334
|
+
self._last_action_id = None
|
|
335
|
+
self._authenticated = False
|
|
336
|
+
self._auth_error = None
|
|
337
|
+
self._shutting_down = False
|
|
338
|
+
self._authenticated_event = asyncio.Event()
|
|
339
|
+
self._hello_event = asyncio.Event()
|
|
340
|
+
self._disconnect_reason = None
|
|
341
|
+
self._fail_pending(RemootioConnectionError("Connection was reset"))
|
|
342
|
+
|
|
343
|
+
async def _shutdown(self, *, notify: bool = True, from_receive: bool = False) -> None:
|
|
344
|
+
if self._shutting_down:
|
|
345
|
+
return
|
|
346
|
+
self._shutting_down = True
|
|
347
|
+
was_authenticated = self._authenticated
|
|
348
|
+
had_connection = self._ws is not None or was_authenticated
|
|
349
|
+
self._authenticated = False
|
|
350
|
+
self._session_key = None
|
|
351
|
+
self._fail_pending(RemootioConnectionError("Disconnected from Remootio"))
|
|
352
|
+
|
|
353
|
+
tasks = [self._ping_watchdog, self._ping_task, *self._callback_tasks]
|
|
354
|
+
if not from_receive:
|
|
355
|
+
tasks.append(self._receive_task)
|
|
356
|
+
for task in tasks:
|
|
357
|
+
if task is not None and not task.done():
|
|
358
|
+
task.cancel()
|
|
359
|
+
for task in tasks:
|
|
360
|
+
if task is not None:
|
|
361
|
+
with suppress(asyncio.CancelledError):
|
|
362
|
+
await task
|
|
363
|
+
self._callback_tasks.clear()
|
|
364
|
+
self._ping_watchdog = None
|
|
365
|
+
self._ping_task = None
|
|
366
|
+
if not from_receive:
|
|
367
|
+
self._receive_task = None
|
|
368
|
+
|
|
369
|
+
if self._ws is not None and not self._ws.closed:
|
|
370
|
+
with suppress(aiohttp.ClientError):
|
|
371
|
+
await self._ws.close()
|
|
372
|
+
self._ws = None
|
|
373
|
+
reason = self._disconnect_reason or "unknown"
|
|
374
|
+
if had_connection:
|
|
375
|
+
_LOGGER.info(
|
|
376
|
+
"Disconnected from Remootio (%s:%s): %s",
|
|
377
|
+
self._host,
|
|
378
|
+
self._port,
|
|
379
|
+
reason,
|
|
380
|
+
)
|
|
381
|
+
self._shutting_down = False
|
|
382
|
+
|
|
383
|
+
if notify and was_authenticated:
|
|
384
|
+
self._notify_connection(False)
|
|
385
|
+
|
|
386
|
+
async def _receive_loop(self) -> None:
|
|
387
|
+
ws = self._ws
|
|
388
|
+
if ws is None:
|
|
389
|
+
return
|
|
390
|
+
try:
|
|
391
|
+
async for message in ws:
|
|
392
|
+
self._clear_ping_watchdog()
|
|
393
|
+
if message.type in {aiohttp.WSMsgType.TEXT, aiohttp.WSMsgType.BINARY}:
|
|
394
|
+
raw = message.data
|
|
395
|
+
if isinstance(raw, bytes):
|
|
396
|
+
raw = raw.decode("utf-8", errors="replace")
|
|
397
|
+
await self._handle_message(raw)
|
|
398
|
+
elif message.type == aiohttp.WSMsgType.ERROR:
|
|
399
|
+
exception = ws.exception()
|
|
400
|
+
self._set_disconnect_reason(f"websocket error: {exception or 'unknown'}")
|
|
401
|
+
break
|
|
402
|
+
elif message.type in {
|
|
403
|
+
aiohttp.WSMsgType.CLOSED,
|
|
404
|
+
aiohttp.WSMsgType.CLOSING,
|
|
405
|
+
}:
|
|
406
|
+
close_code = ws.close_code
|
|
407
|
+
if close_code is not None:
|
|
408
|
+
self._set_disconnect_reason(f"websocket closed (code={close_code})")
|
|
409
|
+
else:
|
|
410
|
+
self._set_disconnect_reason("websocket closed")
|
|
411
|
+
break
|
|
412
|
+
except asyncio.CancelledError:
|
|
413
|
+
raise
|
|
414
|
+
except Exception as err:
|
|
415
|
+
self._set_disconnect_reason(f"receive loop failed: {err}")
|
|
416
|
+
_LOGGER.exception("Remootio receive loop failed")
|
|
417
|
+
finally:
|
|
418
|
+
if self._disconnect_reason is None:
|
|
419
|
+
self._set_disconnect_reason("websocket closed")
|
|
420
|
+
if not self._closed_by_user:
|
|
421
|
+
await self._shutdown(from_receive=True)
|
|
422
|
+
|
|
423
|
+
async def _handle_message(self, raw: str) -> None:
|
|
424
|
+
try:
|
|
425
|
+
frame = json.loads(raw)
|
|
426
|
+
except json.JSONDecodeError:
|
|
427
|
+
_LOGGER.debug("Ignoring non-JSON frame")
|
|
428
|
+
return
|
|
429
|
+
if not isinstance(frame, dict):
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
frame_type = frame.get("type")
|
|
433
|
+
if frame_type == "SERVER_HELLO":
|
|
434
|
+
self._server_hello = ServerHello.from_frame(frame)
|
|
435
|
+
self._hello_event.set()
|
|
436
|
+
return
|
|
437
|
+
if frame_type == "PONG":
|
|
438
|
+
_LOGGER.debug("Received PONG from Remootio (%s:%s)", self._host, self._port)
|
|
439
|
+
return
|
|
440
|
+
if frame_type == "ERROR":
|
|
441
|
+
self._handle_error_frame(str(frame.get("errorMessage", "")))
|
|
442
|
+
return
|
|
443
|
+
if frame_type != "ENCRYPTED":
|
|
444
|
+
_LOGGER.debug("Ignoring frame type %s", frame_type)
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
payload = decrypt_frame(
|
|
449
|
+
frame,
|
|
450
|
+
auth_key=self._credentials.auth_key,
|
|
451
|
+
secret_key=self._credentials.secret_key,
|
|
452
|
+
session_key=self._session_key,
|
|
453
|
+
)
|
|
454
|
+
except RemootioCryptoError as err:
|
|
455
|
+
_LOGGER.debug("Failed to decrypt frame: %s", err)
|
|
456
|
+
if not self._authenticated:
|
|
457
|
+
self._fail_authentication(RemootioAuthenticationError("Invalid API keys"))
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
if "challenge" in payload:
|
|
461
|
+
await self._handle_challenge(payload)
|
|
462
|
+
return
|
|
463
|
+
if "response" in payload:
|
|
464
|
+
self._handle_response(payload)
|
|
465
|
+
return
|
|
466
|
+
if "event" in payload:
|
|
467
|
+
self._handle_event(payload)
|
|
468
|
+
|
|
469
|
+
def _handle_error_frame(self, error_message: str) -> None:
|
|
470
|
+
_LOGGER.debug("Device error: %s", error_message)
|
|
471
|
+
self._set_disconnect_reason(f"device error: {error_message}")
|
|
472
|
+
if error_message in {
|
|
473
|
+
"authentication error",
|
|
474
|
+
"authentication timeout",
|
|
475
|
+
"already authenticated",
|
|
476
|
+
}:
|
|
477
|
+
self._fail_authentication(RemootioAuthenticationError(error_message))
|
|
478
|
+
|
|
479
|
+
async def _handle_challenge(self, payload: dict[str, Any]) -> None:
|
|
480
|
+
try:
|
|
481
|
+
challenge = Challenge.from_payload(payload)
|
|
482
|
+
except ValueError as err:
|
|
483
|
+
self._fail_authentication(RemootioAuthenticationError(str(err)))
|
|
484
|
+
return
|
|
485
|
+
self._session_key = challenge.session_key
|
|
486
|
+
self._last_action_id = challenge.initial_action_id
|
|
487
|
+
try:
|
|
488
|
+
await self._send_action(ActionType.QUERY, wait=False)
|
|
489
|
+
except RemootioError as err:
|
|
490
|
+
self._fail_authentication(err)
|
|
491
|
+
|
|
492
|
+
def _handle_response(self, payload: dict[str, Any]) -> None:
|
|
493
|
+
try:
|
|
494
|
+
response = ActionResponse.from_payload(payload)
|
|
495
|
+
except ValueError:
|
|
496
|
+
_LOGGER.debug("Ignoring malformed action response")
|
|
497
|
+
return
|
|
498
|
+
self._note_response_id(response.id)
|
|
499
|
+
self._state = response.state
|
|
500
|
+
if (
|
|
501
|
+
not self._authenticated
|
|
502
|
+
and response.type is ActionType.QUERY
|
|
503
|
+
and self._session_key is not None
|
|
504
|
+
):
|
|
505
|
+
self._authenticated = True
|
|
506
|
+
self._authenticated_event.set()
|
|
507
|
+
pending = self._pending.pop(response.id, None)
|
|
508
|
+
if pending is not None and not pending.done():
|
|
509
|
+
pending.set_result(response)
|
|
510
|
+
|
|
511
|
+
def _handle_event(self, payload: dict[str, Any]) -> None:
|
|
512
|
+
try:
|
|
513
|
+
event = RemootioEvent.from_payload(payload)
|
|
514
|
+
except ValueError:
|
|
515
|
+
_LOGGER.debug("Ignoring malformed event")
|
|
516
|
+
return
|
|
517
|
+
if event.state is not DoorState.UNKNOWN:
|
|
518
|
+
self._state = event.state
|
|
519
|
+
self._notify_event(event)
|
|
520
|
+
|
|
521
|
+
def _fail_authentication(self, error: BaseException) -> None:
|
|
522
|
+
self._auth_error = error
|
|
523
|
+
self._authenticated_event.set()
|
|
524
|
+
|
|
525
|
+
def _note_response_id(self, response_id: int) -> None:
|
|
526
|
+
if self._last_action_id is None:
|
|
527
|
+
self._last_action_id = response_id
|
|
528
|
+
return
|
|
529
|
+
if response_id > self._last_action_id or (
|
|
530
|
+
response_id == 0 and self._last_action_id == ACTION_ID_MASK - 1
|
|
531
|
+
):
|
|
532
|
+
self._last_action_id = response_id
|
|
533
|
+
|
|
534
|
+
def _allocate_action_id(self) -> int:
|
|
535
|
+
if self._last_action_id is None:
|
|
536
|
+
raise RemootioAuthenticationError("Session is not authenticated")
|
|
537
|
+
self._last_action_id = (self._last_action_id + 1) % ACTION_ID_MASK
|
|
538
|
+
return self._last_action_id
|
|
539
|
+
|
|
540
|
+
async def _request_action(
|
|
541
|
+
self,
|
|
542
|
+
action_type: ActionType,
|
|
543
|
+
duration_minutes: int | None = None,
|
|
544
|
+
) -> ActionResponse:
|
|
545
|
+
if not self.authenticated:
|
|
546
|
+
raise RemootioAuthenticationError("Session is not authenticated")
|
|
547
|
+
if duration_minutes is not None:
|
|
548
|
+
if duration_minutes < 1:
|
|
549
|
+
raise ValueError("duration_minutes must be >= 1")
|
|
550
|
+
version = self.api_version or 0
|
|
551
|
+
if version < MIN_API_VERSION_DURATION:
|
|
552
|
+
raise ValueError(
|
|
553
|
+
"duration_minutes requires Websocket API v3 or later "
|
|
554
|
+
f"(device reports v{version or 'unknown'})"
|
|
555
|
+
)
|
|
556
|
+
response = await self._send_action(action_type, duration_minutes=duration_minutes)
|
|
557
|
+
if not response.success:
|
|
558
|
+
raise RemootioActionError(response)
|
|
559
|
+
return response
|
|
560
|
+
|
|
561
|
+
async def _send_action(
|
|
562
|
+
self,
|
|
563
|
+
action_type: ActionType,
|
|
564
|
+
duration_minutes: int | None = None,
|
|
565
|
+
*,
|
|
566
|
+
wait: bool = True,
|
|
567
|
+
) -> ActionResponse:
|
|
568
|
+
async with self._action_lock:
|
|
569
|
+
action_id = self._allocate_action_id()
|
|
570
|
+
action: dict[str, Any] = {"type": action_type.value, "id": action_id}
|
|
571
|
+
if duration_minutes is not None:
|
|
572
|
+
action["duration"] = duration_minutes
|
|
573
|
+
loop = asyncio.get_running_loop()
|
|
574
|
+
future: asyncio.Future[ActionResponse] = loop.create_future()
|
|
575
|
+
self._pending[action_id] = future
|
|
576
|
+
await self._send_encrypted({"action": action})
|
|
577
|
+
|
|
578
|
+
if not wait:
|
|
579
|
+
return ActionResponse(
|
|
580
|
+
type=action_type,
|
|
581
|
+
id=action_id,
|
|
582
|
+
success=True,
|
|
583
|
+
state=self._state,
|
|
584
|
+
t100ms=0,
|
|
585
|
+
relay_triggered=False,
|
|
586
|
+
error_code="",
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
try:
|
|
590
|
+
return await asyncio.wait_for(future, timeout=self._action_timeout)
|
|
591
|
+
except TimeoutError as err:
|
|
592
|
+
self._pending.pop(action_id, None)
|
|
593
|
+
if not future.done():
|
|
594
|
+
future.cancel()
|
|
595
|
+
raise RemootioTimeoutError(
|
|
596
|
+
f"Timed out waiting for {action_type.value} response"
|
|
597
|
+
) from err
|
|
598
|
+
|
|
599
|
+
async def _send_encrypted(self, payload: dict[str, Any]) -> None:
|
|
600
|
+
if self._session_key is None:
|
|
601
|
+
raise RemootioAuthenticationError("Session is not authenticated")
|
|
602
|
+
frame = encrypt_payload(
|
|
603
|
+
payload,
|
|
604
|
+
auth_key=self._credentials.auth_key,
|
|
605
|
+
session_key=self._session_key,
|
|
606
|
+
)
|
|
607
|
+
await self._send_raw(frame)
|
|
608
|
+
|
|
609
|
+
async def _send_basic(self, frame: dict[str, Any]) -> None:
|
|
610
|
+
await self._send_raw(frame)
|
|
611
|
+
|
|
612
|
+
async def _send_raw(self, frame: dict[str, Any]) -> None:
|
|
613
|
+
if self._ws is None or self._ws.closed:
|
|
614
|
+
raise RemootioConnectionError("Not connected to Remootio")
|
|
615
|
+
await self._ws.send_str(compact_json(frame))
|
|
616
|
+
|
|
617
|
+
async def _ping_loop(self) -> None:
|
|
618
|
+
try:
|
|
619
|
+
while self.connected and not self._closed_by_user:
|
|
620
|
+
await asyncio.sleep(self._ping_interval)
|
|
621
|
+
if not self.connected or self._closed_by_user:
|
|
622
|
+
return
|
|
623
|
+
self._arm_ping_watchdog()
|
|
624
|
+
with suppress(RemootioConnectionError):
|
|
625
|
+
await self._send_basic({"type": "PING"})
|
|
626
|
+
_LOGGER.debug("Sent PING to Remootio (%s:%s)", self._host, self._port)
|
|
627
|
+
except asyncio.CancelledError:
|
|
628
|
+
raise
|
|
629
|
+
|
|
630
|
+
def _arm_ping_watchdog(self) -> None:
|
|
631
|
+
self._clear_ping_watchdog()
|
|
632
|
+
self._ping_watchdog = asyncio.create_task(
|
|
633
|
+
self._ping_watchdog_timeout(),
|
|
634
|
+
name="pyremootio-ping-watchdog",
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
def _clear_ping_watchdog(self) -> None:
|
|
638
|
+
if self._ping_watchdog is not None:
|
|
639
|
+
self._ping_watchdog.cancel()
|
|
640
|
+
self._ping_watchdog = None
|
|
641
|
+
|
|
642
|
+
async def _ping_watchdog_timeout(self) -> None:
|
|
643
|
+
try:
|
|
644
|
+
await asyncio.sleep(self._ping_timeout)
|
|
645
|
+
except asyncio.CancelledError:
|
|
646
|
+
return
|
|
647
|
+
self._set_disconnect_reason(
|
|
648
|
+
f"no response to PING within {int(self._ping_timeout * 1000)} ms"
|
|
649
|
+
)
|
|
650
|
+
if self._ws is not None and not self._ws.closed:
|
|
651
|
+
await self._ws.close()
|
|
652
|
+
|
|
653
|
+
def _set_disconnect_reason(self, reason: str) -> None:
|
|
654
|
+
if self._disconnect_reason is None:
|
|
655
|
+
self._disconnect_reason = reason
|
|
656
|
+
|
|
657
|
+
def _fail_pending(self, error: BaseException) -> None:
|
|
658
|
+
message = str(error)
|
|
659
|
+
for future in self._pending.values():
|
|
660
|
+
if not future.done():
|
|
661
|
+
future.set_exception(RemootioConnectionError(message))
|
|
662
|
+
self._pending.clear()
|
|
663
|
+
|
|
664
|
+
def _notify_event(self, event: RemootioEvent) -> None:
|
|
665
|
+
for callback in list(self._listeners):
|
|
666
|
+
self._schedule_callback(callback, event)
|
|
667
|
+
|
|
668
|
+
def _notify_connection(self, available: bool) -> None:
|
|
669
|
+
for callback in list(self._connection_listeners):
|
|
670
|
+
self._schedule_callback(callback, available)
|
|
671
|
+
|
|
672
|
+
def _schedule_callback(
|
|
673
|
+
self,
|
|
674
|
+
callback: Callable[..., Awaitable[None] | None],
|
|
675
|
+
*args: Any,
|
|
676
|
+
) -> None:
|
|
677
|
+
task = asyncio.create_task(self._run_callback(callback, *args))
|
|
678
|
+
self._callback_tasks.add(task)
|
|
679
|
+
task.add_done_callback(self._callback_tasks.discard)
|
|
680
|
+
|
|
681
|
+
async def _run_callback(
|
|
682
|
+
self,
|
|
683
|
+
callback: Callable[..., Awaitable[None] | None],
|
|
684
|
+
*args: Any,
|
|
685
|
+
) -> None:
|
|
686
|
+
try:
|
|
687
|
+
result = callback(*args)
|
|
688
|
+
if isawaitable(result):
|
|
689
|
+
await result
|
|
690
|
+
except Exception:
|
|
691
|
+
_LOGGER.exception("Remootio listener raised")
|
pyremootio/const.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Protocol constants for the Remootio Websocket API."""
|
|
2
|
+
|
|
3
|
+
DEFAULT_PORT = 8080
|
|
4
|
+
DEFAULT_PING_INTERVAL = 60.0
|
|
5
|
+
DEFAULT_AUTH_TIMEOUT = 15.0
|
|
6
|
+
DEFAULT_ACTION_TIMEOUT = 10.0
|
|
7
|
+
DEFAULT_HELLO_TIMEOUT = 5.0
|
|
8
|
+
INITIAL_RECONNECT_DELAY = 1.0
|
|
9
|
+
MAX_RECONNECT_DELAY = 60.0
|
|
10
|
+
|
|
11
|
+
# Action IDs are 31-bit counters. Next id = (lastActionId + 1) % ACTION_ID_MASK.
|
|
12
|
+
ACTION_ID_MASK = 0x7FFFFFFF
|
|
13
|
+
|
|
14
|
+
HEX_KEY_LENGTH = 64
|
|
15
|
+
MIN_API_VERSION_DURATION = 3
|
pyremootio/crypto.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""AES-256-CBC + HMAC-SHA256 helpers for Remootio ENCRYPTED frames."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from hashlib import sha256
|
|
10
|
+
from os import urandom
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
14
|
+
from cryptography.hazmat.primitives.padding import PKCS7
|
|
15
|
+
|
|
16
|
+
from pyremootio.exceptions import RemootioCryptoError
|
|
17
|
+
|
|
18
|
+
_AES_BLOCK_BITS = 128
|
|
19
|
+
_AES_KEY_LENGTH = 32
|
|
20
|
+
_IV_LENGTH = 16
|
|
21
|
+
_JSON_SEPARATORS = (",", ":")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def compact_json(value: Mapping[str, Any] | list[Any] | str | int | bool | None) -> str:
|
|
25
|
+
"""Serialize JSON without whitespace, matching the device MAC input."""
|
|
26
|
+
return json.dumps(value, separators=_JSON_SEPARATORS)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _b64decode(value: str) -> bytes:
|
|
30
|
+
try:
|
|
31
|
+
return base64.b64decode(value, validate=True)
|
|
32
|
+
except (ValueError, UnicodeError) as err:
|
|
33
|
+
raise RemootioCryptoError("Invalid base64") from err
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _require_aes_key(key: bytes, *, name: str) -> bytes:
|
|
37
|
+
if len(key) != _AES_KEY_LENGTH:
|
|
38
|
+
raise RemootioCryptoError(f"{name} must be {_AES_KEY_LENGTH} bytes")
|
|
39
|
+
return key
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _encryption_key(*, secret_key: str | None, session_key: str | None) -> bytes:
|
|
43
|
+
if session_key is not None:
|
|
44
|
+
try:
|
|
45
|
+
key = _b64decode(session_key)
|
|
46
|
+
except RemootioCryptoError as err:
|
|
47
|
+
raise RemootioCryptoError("session_key is not valid base64") from err
|
|
48
|
+
return _require_aes_key(key, name="session_key")
|
|
49
|
+
if secret_key is None:
|
|
50
|
+
raise RemootioCryptoError("An encryption key is required")
|
|
51
|
+
try:
|
|
52
|
+
key = bytes.fromhex(secret_key)
|
|
53
|
+
except ValueError as err:
|
|
54
|
+
raise RemootioCryptoError("secret_key is not valid hex") from err
|
|
55
|
+
return _require_aes_key(key, name="secret_key")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _auth_key_bytes(auth_key: str) -> bytes:
|
|
59
|
+
try:
|
|
60
|
+
return bytes.fromhex(auth_key)
|
|
61
|
+
except ValueError as err:
|
|
62
|
+
raise RemootioCryptoError("auth_key is not valid hex") from err
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _mac_digest(data: Mapping[str, str], auth_key: str) -> bytes:
|
|
66
|
+
mac_input = compact_json({"iv": data["iv"], "payload": data["payload"]})
|
|
67
|
+
return hmac.new(_auth_key_bytes(auth_key), mac_input.encode("latin-1"), sha256).digest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _mac_for_data(data: Mapping[str, str], auth_key: str) -> str:
|
|
71
|
+
return base64.b64encode(_mac_digest(data, auth_key)).decode("ascii")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _verify_mac(data: Mapping[str, str], auth_key: str, mac: str) -> None:
|
|
75
|
+
"""Compare HMAC digests in constant time, even if ``mac`` is malformed."""
|
|
76
|
+
expected = _mac_digest(data, auth_key)
|
|
77
|
+
received: bytes | None
|
|
78
|
+
try:
|
|
79
|
+
received = base64.b64decode(mac, validate=True)
|
|
80
|
+
except (ValueError, UnicodeError):
|
|
81
|
+
received = None
|
|
82
|
+
if received is None or len(received) != len(expected):
|
|
83
|
+
candidate = bytes(len(expected))
|
|
84
|
+
valid = False
|
|
85
|
+
else:
|
|
86
|
+
candidate = received
|
|
87
|
+
valid = True
|
|
88
|
+
if not hmac.compare_digest(expected, candidate) or not valid:
|
|
89
|
+
raise RemootioCryptoError("ENCRYPTED frame MAC does not match")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def decrypt_frame(
|
|
93
|
+
frame: Mapping[str, Any],
|
|
94
|
+
*,
|
|
95
|
+
auth_key: str,
|
|
96
|
+
secret_key: str | None = None,
|
|
97
|
+
session_key: str | None = None,
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
"""Decrypt an ENCRYPTED frame and verify its HMAC.
|
|
100
|
+
|
|
101
|
+
Use ``secret_key`` before the session is authenticated (AUTH challenge).
|
|
102
|
+
Use ``session_key`` for every later ENCRYPTED frame.
|
|
103
|
+
"""
|
|
104
|
+
if frame.get("type") != "ENCRYPTED":
|
|
105
|
+
raise RemootioCryptoError("Frame is not an ENCRYPTED frame")
|
|
106
|
+
data = frame.get("data")
|
|
107
|
+
mac = frame.get("mac")
|
|
108
|
+
if not isinstance(data, Mapping) or not isinstance(mac, str):
|
|
109
|
+
raise RemootioCryptoError("ENCRYPTED frame is missing data or mac")
|
|
110
|
+
iv_b64 = data.get("iv")
|
|
111
|
+
payload_b64 = data.get("payload")
|
|
112
|
+
if not isinstance(iv_b64, str) or not isinstance(payload_b64, str):
|
|
113
|
+
raise RemootioCryptoError("ENCRYPTED frame data is invalid")
|
|
114
|
+
|
|
115
|
+
_verify_mac({"iv": iv_b64, "payload": payload_b64}, auth_key, mac)
|
|
116
|
+
|
|
117
|
+
key = _encryption_key(secret_key=secret_key, session_key=session_key)
|
|
118
|
+
try:
|
|
119
|
+
iv = _b64decode(iv_b64)
|
|
120
|
+
if len(iv) != _IV_LENGTH:
|
|
121
|
+
raise RemootioCryptoError("IV must be 16 bytes")
|
|
122
|
+
ciphertext = _b64decode(payload_b64)
|
|
123
|
+
decryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
|
|
124
|
+
padded = decryptor.update(ciphertext) + decryptor.finalize()
|
|
125
|
+
unpadder = PKCS7(_AES_BLOCK_BITS).unpadder()
|
|
126
|
+
plaintext = unpadder.update(padded) + unpadder.finalize()
|
|
127
|
+
decoded = plaintext.decode("latin-1")
|
|
128
|
+
parsed = json.loads(decoded)
|
|
129
|
+
except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as err:
|
|
130
|
+
raise RemootioCryptoError("Failed to decrypt ENCRYPTED frame") from err
|
|
131
|
+
if not isinstance(parsed, dict):
|
|
132
|
+
raise RemootioCryptoError("Decrypted payload is not a JSON object")
|
|
133
|
+
return parsed
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def encrypt_payload(
|
|
137
|
+
payload: Mapping[str, Any],
|
|
138
|
+
*,
|
|
139
|
+
auth_key: str,
|
|
140
|
+
session_key: str | None = None,
|
|
141
|
+
secret_key: str | None = None,
|
|
142
|
+
iv: bytes | None = None,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
"""Wrap an unencrypted payload in an ENCRYPTED frame.
|
|
145
|
+
|
|
146
|
+
The device encrypts the AUTH challenge with ``secret_key``. Every later
|
|
147
|
+
frame uses ``session_key``.
|
|
148
|
+
"""
|
|
149
|
+
if iv is None:
|
|
150
|
+
iv = urandom(_IV_LENGTH)
|
|
151
|
+
elif len(iv) != _IV_LENGTH:
|
|
152
|
+
raise RemootioCryptoError("IV must be 16 bytes")
|
|
153
|
+
|
|
154
|
+
key = _encryption_key(secret_key=secret_key, session_key=session_key)
|
|
155
|
+
plaintext = compact_json(payload).encode("latin-1")
|
|
156
|
+
padder = PKCS7(_AES_BLOCK_BITS).padder()
|
|
157
|
+
padded = padder.update(plaintext) + padder.finalize()
|
|
158
|
+
encryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
|
|
159
|
+
ciphertext = encryptor.update(padded) + encryptor.finalize()
|
|
160
|
+
|
|
161
|
+
data = {
|
|
162
|
+
"iv": base64.b64encode(iv).decode("ascii"),
|
|
163
|
+
"payload": base64.b64encode(ciphertext).decode("ascii"),
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
"type": "ENCRYPTED",
|
|
167
|
+
"data": data,
|
|
168
|
+
"mac": _mac_for_data(data, auth_key),
|
|
169
|
+
}
|
pyremootio/exceptions.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Exceptions raised by the Remootio client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pyremootio.models import ActionResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RemootioError(Exception):
|
|
12
|
+
"""Base error for the Remootio client."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RemootioConnectionError(RemootioError):
|
|
16
|
+
"""The websocket connection could not be established or was lost."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RemootioAuthenticationError(RemootioError):
|
|
20
|
+
"""The AUTH challenge/response flow failed."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RemootioCryptoError(RemootioError):
|
|
24
|
+
"""Encryption, decryption, or MAC verification failed."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RemootioTimeoutError(RemootioError):
|
|
28
|
+
"""A request did not complete within the expected time."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RemootioActionError(RemootioError):
|
|
32
|
+
"""The device rejected an action or reported success=false."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, response: ActionResponse) -> None:
|
|
35
|
+
self.response = response
|
|
36
|
+
message = response.error_code or f"{response.type} failed"
|
|
37
|
+
super().__init__(message)
|
pyremootio/models.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Typed models for Remootio Websocket API frames, actions, and events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pyremootio.const import HEX_KEY_LENGTH
|
|
12
|
+
|
|
13
|
+
_HEX_KEY_RE = re.compile(rf"^[0-9A-Fa-f]{{{HEX_KEY_LENGTH}}}$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DoorState(StrEnum):
|
|
17
|
+
"""Gate / garage door sensor state reported by the device."""
|
|
18
|
+
|
|
19
|
+
OPEN = "open"
|
|
20
|
+
CLOSED = "closed"
|
|
21
|
+
NO_SENSOR = "no sensor"
|
|
22
|
+
UNKNOWN = "unknown"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ActionType(StrEnum):
|
|
26
|
+
"""Encrypted action types the client can send."""
|
|
27
|
+
|
|
28
|
+
QUERY = "QUERY"
|
|
29
|
+
TRIGGER = "TRIGGER"
|
|
30
|
+
TRIGGER_SECONDARY = "TRIGGER_SECONDARY"
|
|
31
|
+
OPEN = "OPEN"
|
|
32
|
+
CLOSE = "CLOSE"
|
|
33
|
+
RESTART = "RESTART"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class EventType(StrEnum):
|
|
37
|
+
"""Device event types (logging mode determines which are emitted)."""
|
|
38
|
+
|
|
39
|
+
STATE_CHANGE = "StateChange"
|
|
40
|
+
RELAY_TRIGGER = "RelayTrigger"
|
|
41
|
+
SECONDARY_RELAY_TRIGGER = "SecondaryRelayTrigger"
|
|
42
|
+
OUTPUT_HELD_ACTIVE = "OutputHeldActive"
|
|
43
|
+
SECONDARY_OUTPUT_HELD_ACTIVE = "SecondaryOutputHeldActive"
|
|
44
|
+
CONNECTED = "Connected"
|
|
45
|
+
LEFT_OPEN = "LeftOpen"
|
|
46
|
+
KEY_MANAGEMENT = "KeyManagement"
|
|
47
|
+
RESTART = "Restart"
|
|
48
|
+
MANUAL_BUTTON_PUSHED = "ManualButtonPushed"
|
|
49
|
+
MANUAL_BUTTON_ENABLED = "ManualButtonEnabled"
|
|
50
|
+
MANUAL_BUTTON_DISABLED = "ManualButtonDisabled"
|
|
51
|
+
DOORBELL_PUSHED = "DoorbellPushed"
|
|
52
|
+
DOORBELL_ENABLED = "DoorbellEnabled"
|
|
53
|
+
DOORBELL_DISABLED = "DoorbellDisabled"
|
|
54
|
+
SENSOR_ENABLED = "SensorEnabled"
|
|
55
|
+
SENSOR_FLIPPED = "SensorFlipped"
|
|
56
|
+
SENSOR_DISABLED = "SensorDisabled"
|
|
57
|
+
OUTPUT1_ACTIVATED = "Output1Activated"
|
|
58
|
+
OUTPUT1_DEACTIVATED = "Output1Deactivated"
|
|
59
|
+
OUTPUT2_ACTIVATED = "Output2Activated"
|
|
60
|
+
OUTPUT2_DEACTIVATED = "Output2Deactivated"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class KeyType(StrEnum):
|
|
64
|
+
"""Key class reported in event data."""
|
|
65
|
+
|
|
66
|
+
MASTER_KEY = "master key"
|
|
67
|
+
UNIQUE_KEY = "unique key"
|
|
68
|
+
GUEST_KEY = "guest key"
|
|
69
|
+
API_KEY = "api key"
|
|
70
|
+
SMART_HOME = "smart home"
|
|
71
|
+
AUTOMATION = "automation"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ConnectionVia(StrEnum):
|
|
75
|
+
"""How a key reached the device for an event."""
|
|
76
|
+
|
|
77
|
+
BLUETOOTH = "bluetooth"
|
|
78
|
+
WIFI = "wifi"
|
|
79
|
+
INTERNET = "internet"
|
|
80
|
+
AUTOOPEN = "autoopen"
|
|
81
|
+
UNKNOWN = "unknown"
|
|
82
|
+
NONE = "none"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class DeviceErrorMessage(StrEnum):
|
|
86
|
+
"""ERROR frame errorMessage values sent by the device."""
|
|
87
|
+
|
|
88
|
+
JSON_ERROR = "json error"
|
|
89
|
+
INPUT_ERROR = "input error"
|
|
90
|
+
INTERNAL_ERROR = "internal error"
|
|
91
|
+
CONNECTION_TIMEOUT = "connection timeout"
|
|
92
|
+
AUTHENTICATION_TIMEOUT = "authentication timeout"
|
|
93
|
+
ALREADY_AUTHENTICATED = "already authenticated"
|
|
94
|
+
AUTHENTICATION_ERROR = "authentication error"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ActionErrorCode(StrEnum):
|
|
98
|
+
"""errorCode values in action responses."""
|
|
99
|
+
|
|
100
|
+
NONE = ""
|
|
101
|
+
RELAY_BUSY = "ERR_RELAY_BUSY"
|
|
102
|
+
NO_SENSOR = "ERR_NO_SENSOR"
|
|
103
|
+
INVALID_REQUEST = "ERR_INVALID_REQUEST"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parse_door_state(value: object) -> DoorState:
|
|
107
|
+
if isinstance(value, DoorState):
|
|
108
|
+
return value
|
|
109
|
+
if isinstance(value, str):
|
|
110
|
+
try:
|
|
111
|
+
return DoorState(value)
|
|
112
|
+
except ValueError:
|
|
113
|
+
return DoorState.UNKNOWN
|
|
114
|
+
return DoorState.UNKNOWN
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True)
|
|
118
|
+
class Credentials:
|
|
119
|
+
"""API Secret Key and API Auth Key from the Remootio app."""
|
|
120
|
+
|
|
121
|
+
secret_key: str
|
|
122
|
+
auth_key: str
|
|
123
|
+
|
|
124
|
+
def __post_init__(self) -> None:
|
|
125
|
+
object.__setattr__(self, "secret_key", self.secret_key.strip())
|
|
126
|
+
object.__setattr__(self, "auth_key", self.auth_key.strip())
|
|
127
|
+
if not _HEX_KEY_RE.fullmatch(self.secret_key):
|
|
128
|
+
raise ValueError("secret_key must be a 64-character hex string")
|
|
129
|
+
if not _HEX_KEY_RE.fullmatch(self.auth_key):
|
|
130
|
+
raise ValueError("auth_key must be a 64-character hex string")
|
|
131
|
+
|
|
132
|
+
def __repr__(self) -> str:
|
|
133
|
+
return "Credentials(secret_key='***', auth_key='***')"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True, slots=True)
|
|
137
|
+
class ServerHello:
|
|
138
|
+
"""Identity returned by a HELLO / SERVER_HELLO exchange."""
|
|
139
|
+
|
|
140
|
+
api_version: int
|
|
141
|
+
message: str
|
|
142
|
+
serial_number: str | None = None
|
|
143
|
+
remootio_version: str | None = None
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def from_frame(cls, frame: Mapping[str, Any]) -> ServerHello:
|
|
147
|
+
api_version = frame.get("apiVersion", 1)
|
|
148
|
+
try:
|
|
149
|
+
parsed_version = int(api_version)
|
|
150
|
+
except (TypeError, ValueError):
|
|
151
|
+
parsed_version = 1
|
|
152
|
+
serial = frame.get("serialNumber")
|
|
153
|
+
version = frame.get("remootioVersion")
|
|
154
|
+
return cls(
|
|
155
|
+
api_version=parsed_version,
|
|
156
|
+
message=str(frame.get("message", "")),
|
|
157
|
+
serial_number=str(serial) if serial else None,
|
|
158
|
+
remootio_version=str(version) if version else None,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True, slots=True)
|
|
163
|
+
class Challenge:
|
|
164
|
+
"""AUTH challenge payload: session key plus the starting action counter."""
|
|
165
|
+
|
|
166
|
+
session_key: str
|
|
167
|
+
initial_action_id: int
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def from_payload(cls, payload: Mapping[str, Any]) -> Challenge:
|
|
171
|
+
challenge = payload.get("challenge")
|
|
172
|
+
if not isinstance(challenge, Mapping):
|
|
173
|
+
raise ValueError("AUTH challenge payload is missing challenge")
|
|
174
|
+
session_key = challenge.get("sessionKey")
|
|
175
|
+
initial_action_id = challenge.get("initialActionId")
|
|
176
|
+
if not isinstance(session_key, str) or not isinstance(initial_action_id, int):
|
|
177
|
+
raise ValueError("AUTH challenge payload is invalid")
|
|
178
|
+
return cls(session_key=session_key, initial_action_id=initial_action_id)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True, slots=True)
|
|
182
|
+
class ActionResponse:
|
|
183
|
+
"""Decrypted response to an encrypted action."""
|
|
184
|
+
|
|
185
|
+
type: ActionType
|
|
186
|
+
id: int
|
|
187
|
+
success: bool
|
|
188
|
+
state: DoorState
|
|
189
|
+
t100ms: int
|
|
190
|
+
relay_triggered: bool
|
|
191
|
+
error_code: str
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def from_payload(cls, payload: Mapping[str, Any]) -> ActionResponse:
|
|
195
|
+
response = payload.get("response")
|
|
196
|
+
if not isinstance(response, Mapping):
|
|
197
|
+
raise ValueError("Action response payload is missing response")
|
|
198
|
+
raw_type = str(response.get("type", ""))
|
|
199
|
+
try:
|
|
200
|
+
action_type = ActionType(raw_type)
|
|
201
|
+
except ValueError as err:
|
|
202
|
+
raise ValueError(f"Unknown action response type: {raw_type}") from err
|
|
203
|
+
return cls(
|
|
204
|
+
type=action_type,
|
|
205
|
+
id=int(response.get("id", 0)),
|
|
206
|
+
success=bool(response.get("success", False)),
|
|
207
|
+
state=_parse_door_state(response.get("state")),
|
|
208
|
+
t100ms=int(response.get("t100ms", 0)),
|
|
209
|
+
relay_triggered=bool(response.get("relayTriggered", False)),
|
|
210
|
+
error_code=str(response.get("errorCode", "")),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass(frozen=True, slots=True)
|
|
215
|
+
class RemootioEvent:
|
|
216
|
+
"""Decrypted event pushed by the device."""
|
|
217
|
+
|
|
218
|
+
type: str
|
|
219
|
+
state: DoorState
|
|
220
|
+
cnt: int
|
|
221
|
+
t100ms: int
|
|
222
|
+
data: dict[str, Any] | None = None
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def event_type(self) -> EventType | None:
|
|
226
|
+
"""Known EventType, or None if the device sent an unrecognized type."""
|
|
227
|
+
try:
|
|
228
|
+
return EventType(self.type)
|
|
229
|
+
except ValueError:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def from_payload(cls, payload: Mapping[str, Any]) -> RemootioEvent:
|
|
234
|
+
event = payload.get("event")
|
|
235
|
+
if not isinstance(event, Mapping):
|
|
236
|
+
raise ValueError("Event payload is missing event")
|
|
237
|
+
data = event.get("data")
|
|
238
|
+
return cls(
|
|
239
|
+
type=str(event.get("type", "")),
|
|
240
|
+
state=_parse_door_state(event.get("state")),
|
|
241
|
+
cnt=int(event.get("cnt", 0)),
|
|
242
|
+
t100ms=int(event.get("t100ms", 0)),
|
|
243
|
+
data=dict(data) if isinstance(data, Mapping) else None,
|
|
244
|
+
)
|
pyremootio/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pyremootio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python client for the Remootio Websocket API
|
|
5
|
+
Project-URL: Homepage, https://github.com/remootio/pyremootio
|
|
6
|
+
Project-URL: Documentation, https://github.com/remootio/remootio-api-documentation
|
|
7
|
+
Project-URL: Source, https://github.com/remootio/pyremootio
|
|
8
|
+
Project-URL: Issues, https://github.com/remootio/pyremootio/issues
|
|
9
|
+
Author-email: Remootio <hello@remootio.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: asyncio,garage,gate,remootio,websocket
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Home Automation
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
23
|
+
Requires-Dist: cryptography>=42.0.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.11.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.6.0; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# pyremootio
|
|
32
|
+
|
|
33
|
+
Async Python client for the [Remootio](https://www.remootio.com) local Websocket API.
|
|
34
|
+
|
|
35
|
+
Enable the Websocket API in the Remootio app and copy the API Secret Key, API Auth Key, and device IP.
|
|
36
|
+
|
|
37
|
+
Protocol details: [Remootio Websocket API documentation](https://github.com/remootio/remootio-api-documentation).
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
Python 3.12 or newer:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install pyremootio
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
From a clone of this repo (includes tests and examples):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python3.12 -m venv .venv
|
|
51
|
+
.venv/bin/pip install -e ".[dev]"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Run examples with `.venv/bin/python`.
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import asyncio
|
|
60
|
+
import aiohttp
|
|
61
|
+
from pyremootio import RemootioClient
|
|
62
|
+
|
|
63
|
+
async def main() -> None:
|
|
64
|
+
async with aiohttp.ClientSession() as session:
|
|
65
|
+
client = RemootioClient(
|
|
66
|
+
"<ip_address>", # IP address of your Remootio device
|
|
67
|
+
"<secret_key>", # API Secret Key from the Remootio app
|
|
68
|
+
"<auth_key>", # API Auth Key from the Remootio app
|
|
69
|
+
session,
|
|
70
|
+
)
|
|
71
|
+
await client.connect()
|
|
72
|
+
try:
|
|
73
|
+
response = await client.trigger()
|
|
74
|
+
print("TRIGGER success=%s state=%s" % (response.success, response.state))
|
|
75
|
+
finally:
|
|
76
|
+
await client.disconnect()
|
|
77
|
+
|
|
78
|
+
asyncio.run(main())
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`connect()` runs HELLO, AUTH, and the first QUERY action to authenticate the client. After that, `listen()` receives device events.
|
|
82
|
+
|
|
83
|
+
Logging uses the standard `pyremootio` logger:
|
|
84
|
+
|
|
85
|
+
| Level | What you see |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `DEBUG` | PING / PONG keepalive |
|
|
88
|
+
| `INFO` | connect, authenticate, disconnect (with reason) |
|
|
89
|
+
| `WARNING` | connection lost, reconnect failed |
|
|
90
|
+
| `ERROR` | unexpected receive-loop or listener failures |
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
import logging
|
|
94
|
+
logging.getLogger("pyremootio").setLevel(logging.DEBUG)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Events
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
async def on_event(event):
|
|
101
|
+
print(event.type, event.state)
|
|
102
|
+
|
|
103
|
+
unsubscribe = client.listen(on_event)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`StateChange` is sent when a sensor is installed. Enable API logging in the app for the full event set.
|
|
107
|
+
|
|
108
|
+
### Actions
|
|
109
|
+
|
|
110
|
+
| Method | Device action |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| `query()` | Current door state |
|
|
113
|
+
| `open(duration_minutes=None)` | Open if closed (sensor required) |
|
|
114
|
+
| `close(duration_minutes=None)` | Close if open (sensor required) |
|
|
115
|
+
| `trigger(duration_minutes=None)` | Pulse the control output |
|
|
116
|
+
| `trigger_secondary(duration_minutes=None)` | Pulse the free relay output |
|
|
117
|
+
| `restart()` | Reboot the device (connection drops) |
|
|
118
|
+
|
|
119
|
+
`duration_minutes` holds the output active. It is rejected unless `api_version` is 3 or later. Failed actions raise `RemootioActionError`.
|
|
120
|
+
|
|
121
|
+
## Examples
|
|
122
|
+
|
|
123
|
+
- [`examples/trigger.py`](examples/trigger.py) — connect, trigger, wait for `StateChange`
|
|
124
|
+
- [`examples/log_events.py`](examples/log_events.py) — log device events to stdout
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
.venv/bin/python examples/trigger.py --host <ip_address> --secret-key <secret_key> --auth-key <auth_key>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Tests
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
pytest
|
|
134
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pyremootio/__init__.py,sha256=Vlq22M3JfKLspxtF4q1T3ZjNV1aEzMdCfv8AoKuYlTw,976
|
|
2
|
+
pyremootio/client.py,sha256=_KBxE1RV7ziQ1AHgZk2YUkjPq97BlfsMF6oYa_5BhlI,25158
|
|
3
|
+
pyremootio/const.py,sha256=WqDZZoXYtjyGHSwK5TZUaFTd5HJ4aqNzWWR_OEjUJWM,410
|
|
4
|
+
pyremootio/crypto.py,sha256=soQPeOkqHBlzfBXnUzSrr9cfscWjJt_OUA2m7tE5kM4,6040
|
|
5
|
+
pyremootio/exceptions.py,sha256=8TvD4VsdkQE-sW2O0Y6gji8vwM9WhXl0WMTz7krkuho,1008
|
|
6
|
+
pyremootio/models.py,sha256=uauElgvm0Y6sZVPqS0szKS9HVK5gwA35wZQjHIjoDYk,7604
|
|
7
|
+
pyremootio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
pyremootio-0.1.0.dist-info/METADATA,sha256=IHw0H5i_dumEfltoFTKjBUIUMLf0h6JHXe-L9K7v1aU,4093
|
|
9
|
+
pyremootio-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
pyremootio-0.1.0.dist-info/licenses/LICENSE,sha256=XrOUPtCCGPf3nNI_ZEzQ9XxhrykpzQkugQq7Ll-jMEg,1075
|
|
11
|
+
pyremootio-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Remootio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING WITHOUT LIMITATION THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|