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 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
+ }
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.