remoboard 1.0.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.
remoboard/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """Remoboard Python client.
2
+
3
+ Drive remote text input on a phone running the Remoboard keyboard, over the versioned
4
+ JSON protocol (v1) carried on a WebSocket.
5
+
6
+ Async::
7
+
8
+ import asyncio
9
+ from remoboard import RemoboardClient
10
+
11
+ async def main():
12
+ async with RemoboardClient(host="192.168.1.20", pin="482103") as rb:
13
+ await rb.type("Hello 世界")
14
+ await rb.enter()
15
+
16
+ asyncio.run(main())
17
+
18
+ Sync::
19
+
20
+ from remoboard import RemoboardSync
21
+
22
+ with RemoboardSync(host="192.168.1.20", pin="482103") as rb:
23
+ rb.type("Hello 世界")
24
+ rb.enter()
25
+ """
26
+
27
+ from .client import (
28
+ RemoboardClient,
29
+ RemoboardSync,
30
+ PairingError,
31
+ RemoboardError,
32
+ PROTOCOL_VERSION,
33
+ DEFAULT_PORT,
34
+ )
35
+
36
+ __all__ = [
37
+ "RemoboardClient",
38
+ "RemoboardSync",
39
+ "PairingError",
40
+ "RemoboardError",
41
+ "PROTOCOL_VERSION",
42
+ "DEFAULT_PORT",
43
+ ]
44
+ __version__ = "1.0.0"
remoboard/client.py ADDED
@@ -0,0 +1,284 @@
1
+ """Remoboard WebSocket client (async + sync)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import threading
8
+ from typing import Awaitable, Callable, Optional
9
+
10
+ import websockets
11
+
12
+ PROTOCOL_VERSION = 1
13
+ DEFAULT_PORT = 7777
14
+
15
+ _DIRECTIONS = ("left", "right", "up", "down")
16
+
17
+
18
+ class RemoboardError(Exception):
19
+ """Base error for the Remoboard client."""
20
+
21
+
22
+ class PairingError(RemoboardError):
23
+ """Raised when the phone rejects the PIN."""
24
+
25
+ def __init__(self, reason: Optional[str] = None):
26
+ super().__init__(f"Pairing rejected ({reason or 'pin'})")
27
+ self.reason = reason
28
+
29
+
30
+ class RemoboardClient:
31
+ """Async client. Connect, pair with the PIN, then send input.
32
+
33
+ Optional callbacks (called from the receive loop):
34
+ on_context(before, after), on_quickwords(list), on_clipboard(text), on_info(text)
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ host: str,
40
+ pin: str,
41
+ port: int = DEFAULT_PORT,
42
+ pair_timeout: float = 8.0,
43
+ on_context: Optional[Callable[[str, str], None]] = None,
44
+ on_quickwords: Optional[Callable[[list], None]] = None,
45
+ on_clipboard: Optional[Callable[[str], None]] = None,
46
+ on_info: Optional[Callable[[str], None]] = None,
47
+ ):
48
+ if not host:
49
+ raise ValueError("host is required")
50
+ if not pin:
51
+ raise ValueError("pin is required")
52
+ self.host = host
53
+ self.pin = str(pin)
54
+ self.port = port
55
+ self.pair_timeout = pair_timeout
56
+ self.on_context = on_context
57
+ self.on_quickwords = on_quickwords
58
+ self.on_clipboard = on_clipboard
59
+ self.on_info = on_info
60
+
61
+ self._ws = None
62
+ self._seq = 0
63
+ self._paired = False
64
+ self._recv_task: Optional[asyncio.Task] = None
65
+ self._paired_event = asyncio.Event()
66
+ self._deny_reason: Optional[str] = None
67
+ self._clip_waiters: "list[asyncio.Future]" = []
68
+
69
+ @property
70
+ def url(self) -> str:
71
+ return f"ws://{self.host}:{self.port}/ws"
72
+
73
+ @property
74
+ def paired(self) -> bool:
75
+ return self._paired
76
+
77
+ # ---- lifecycle ----
78
+
79
+ async def connect(self) -> "RemoboardClient":
80
+ """Open the socket and wait until the phone confirms pairing."""
81
+ self._ws = await websockets.connect(self.url, open_timeout=self.pair_timeout)
82
+ self._recv_task = asyncio.ensure_future(self._recv_loop())
83
+ await self._send({"t": "hello", "pin": self.pin})
84
+ try:
85
+ await asyncio.wait_for(self._paired_event.wait(), timeout=self.pair_timeout)
86
+ except asyncio.TimeoutError:
87
+ await self.close()
88
+ if self._deny_reason is not None:
89
+ raise PairingError(self._deny_reason)
90
+ raise RemoboardError(f"Timed out waiting to pair with {self.url}")
91
+ if not self._paired:
92
+ await self.close()
93
+ raise PairingError(self._deny_reason)
94
+ return self
95
+
96
+ async def close(self) -> None:
97
+ if self._recv_task:
98
+ self._recv_task.cancel()
99
+ self._recv_task = None
100
+ if self._ws:
101
+ await self._ws.close()
102
+ self._ws = None
103
+ self._paired = False
104
+
105
+ async def __aenter__(self) -> "RemoboardClient":
106
+ return await self.connect()
107
+
108
+ async def __aexit__(self, *exc) -> None:
109
+ await self.close()
110
+
111
+ # ---- receive ----
112
+
113
+ async def _recv_loop(self) -> None:
114
+ try:
115
+ async for raw in self._ws:
116
+ try:
117
+ msg = json.loads(raw)
118
+ except (ValueError, TypeError):
119
+ continue
120
+ self._handle(msg)
121
+ except (websockets.ConnectionClosed, asyncio.CancelledError):
122
+ pass
123
+ finally:
124
+ for fut in self._clip_waiters:
125
+ if not fut.done():
126
+ fut.set_exception(RemoboardError("connection closed"))
127
+ self._clip_waiters.clear()
128
+
129
+ def _handle(self, msg: dict) -> None:
130
+ t = msg.get("t")
131
+ if t == "paired":
132
+ self._paired = True
133
+ self._paired_event.set()
134
+ elif t == "deny":
135
+ self._deny_reason = msg.get("reason")
136
+ self._paired_event.set()
137
+ elif t == "context":
138
+ if self.on_context:
139
+ self.on_context(msg.get("before", ""), msg.get("after", ""))
140
+ elif t == "quickwords":
141
+ if self.on_quickwords:
142
+ self.on_quickwords(msg.get("items", []))
143
+ elif t == "clip":
144
+ text = msg.get("text", "")
145
+ if self._clip_waiters:
146
+ fut = self._clip_waiters.pop(0)
147
+ if not fut.done():
148
+ fut.set_result(text)
149
+ if self.on_clipboard:
150
+ self.on_clipboard(text)
151
+ elif t == "info":
152
+ if self.on_info:
153
+ self.on_info(msg.get("message", ""))
154
+
155
+ # ---- send ----
156
+
157
+ async def _send(self, obj: dict) -> None:
158
+ if self._ws is None:
159
+ raise RemoboardError("not connected")
160
+ await self._ws.send(json.dumps({"v": PROTOCOL_VERSION, **obj}))
161
+
162
+ def _require_paired(self) -> None:
163
+ if not self._paired:
164
+ raise RemoboardError("not paired — await connect() first")
165
+
166
+ async def type(self, text: str) -> None:
167
+ """Insert text at the phone's cursor."""
168
+ self._require_paired()
169
+ if text:
170
+ await self._send({"t": "input", "text": str(text), "seq": self._next_seq()})
171
+
172
+ async def enter(self) -> None:
173
+ """Insert a newline / submit."""
174
+ self._require_paired()
175
+ await self._send({"t": "input", "text": "\n", "seq": self._next_seq()})
176
+
177
+ async def backspace(self) -> None:
178
+ """Delete one character backwards."""
179
+ self._require_paired()
180
+ await self._send({"t": "delete", "seq": self._next_seq()})
181
+
182
+ async def move(self, direction: str) -> None:
183
+ """Move the cursor: 'left' | 'right' | 'up' | 'down'."""
184
+ self._require_paired()
185
+ if direction not in _DIRECTIONS:
186
+ raise ValueError(f"bad direction: {direction}")
187
+ await self._send({"t": "move", "dir": direction, "seq": self._next_seq()})
188
+
189
+ async def set_clipboard(self, text: str) -> None:
190
+ """Write text into the phone's system clipboard."""
191
+ self._require_paired()
192
+ await self._send({"t": "clip-set", "text": str(text)})
193
+
194
+ async def get_clipboard(self, timeout: float = 5.0) -> str:
195
+ """Read the phone's clipboard."""
196
+ self._require_paired()
197
+ loop = asyncio.get_event_loop()
198
+ fut: asyncio.Future = loop.create_future()
199
+ self._clip_waiters.append(fut)
200
+ await self._send({"t": "clip-get"})
201
+ try:
202
+ return await asyncio.wait_for(fut, timeout=timeout)
203
+ except asyncio.TimeoutError:
204
+ if fut in self._clip_waiters:
205
+ self._clip_waiters.remove(fut)
206
+ raise RemoboardError("get_clipboard timed out")
207
+
208
+ async def set_quick_words(self, words) -> None:
209
+ """Replace the user's quick words on the phone."""
210
+ self._require_paired()
211
+ await self._send({"t": "words-set", "items": [str(w) for w in words]})
212
+
213
+ async def handoff(self, text: str) -> None:
214
+ """Hand text to the phone's host app."""
215
+ self._require_paired()
216
+ await self._send({"t": "handoff", "text": str(text)})
217
+
218
+ async def ping(self) -> None:
219
+ await self._send({"t": "ping"})
220
+
221
+ def _next_seq(self) -> int:
222
+ s = self._seq
223
+ self._seq += 1
224
+ return s
225
+
226
+
227
+ class RemoboardSync:
228
+ """Blocking wrapper around :class:`RemoboardClient`, backed by a private event loop.
229
+
230
+ Useful for scripts that don't want to deal with asyncio::
231
+
232
+ with RemoboardSync(host="...", pin="...") as rb:
233
+ rb.type("hi")
234
+ rb.enter()
235
+ """
236
+
237
+ def __init__(self, host: str, pin: str, port: int = DEFAULT_PORT, **kwargs):
238
+ self._loop = asyncio.new_event_loop()
239
+ self._thread = threading.Thread(target=self._loop.run_forever, daemon=True)
240
+ self._thread.start()
241
+ self._client = RemoboardClient(host=host, pin=pin, port=port, **kwargs)
242
+
243
+ def _run(self, coro: Awaitable):
244
+ return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
245
+
246
+ def connect(self) -> "RemoboardSync":
247
+ self._run(self._client.connect())
248
+ return self
249
+
250
+ def type(self, text: str) -> None:
251
+ self._run(self._client.type(text))
252
+
253
+ def enter(self) -> None:
254
+ self._run(self._client.enter())
255
+
256
+ def backspace(self) -> None:
257
+ self._run(self._client.backspace())
258
+
259
+ def move(self, direction: str) -> None:
260
+ self._run(self._client.move(direction))
261
+
262
+ def set_clipboard(self, text: str) -> None:
263
+ self._run(self._client.set_clipboard(text))
264
+
265
+ def get_clipboard(self, timeout: float = 5.0) -> str:
266
+ return self._run(self._client.get_clipboard(timeout))
267
+
268
+ def set_quick_words(self, words) -> None:
269
+ self._run(self._client.set_quick_words(words))
270
+
271
+ def handoff(self, text: str) -> None:
272
+ self._run(self._client.handoff(text))
273
+
274
+ def close(self) -> None:
275
+ try:
276
+ self._run(self._client.close())
277
+ finally:
278
+ self._loop.call_soon_threadsafe(self._loop.stop)
279
+
280
+ def __enter__(self) -> "RemoboardSync":
281
+ return self.connect()
282
+
283
+ def __exit__(self, *exc) -> None:
284
+ self.close()
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: remoboard
3
+ Version: 1.0.0
4
+ Summary: Type into a phone running the Remoboard keyboard from Python — official client for the Remoboard WebSocket protocol.
5
+ Project-URL: Homepage, https://github.com/everettjf/Remoboard
6
+ Project-URL: Repository, https://github.com/everettjf/Remoboard
7
+ Author: everettjf
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: automation,input,ios,remoboard,remote-keyboard,websocket
11
+ Requires-Python: >=3.9
12
+ Requires-Dist: websockets>=12.0
13
+ Description-Content-Type: text/markdown
14
+
15
+ # remoboard (Python)
16
+
17
+ Official Python client for [Remoboard](https://github.com/everettjf/Remoboard) — type
18
+ into a phone running the Remoboard keyboard, from code.
19
+
20
+ It connects to the WebSocket server the Remoboard keyboard extension hosts on the phone
21
+ and speaks the versioned JSON protocol (v1).
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install remoboard
27
+ ```
28
+
29
+ Requires Python 3.9+ (depends on `websockets`).
30
+
31
+ ## Quick start
32
+
33
+ On the phone: switch to the Remoboard keyboard. It shows a URL like `http://192.168.1.20:7777`
34
+ and a **PIN**.
35
+
36
+ ### Async
37
+
38
+ ```python
39
+ import asyncio
40
+ from remoboard import RemoboardClient
41
+
42
+ async def main():
43
+ async with RemoboardClient(host="192.168.1.20", pin="482103") as rb:
44
+ await rb.type("Hello 世界 👋")
45
+ await rb.enter()
46
+ await rb.type("second line")
47
+ print(await rb.get_clipboard())
48
+
49
+ asyncio.run(main())
50
+ ```
51
+
52
+ ### Sync (no asyncio)
53
+
54
+ ```python
55
+ from remoboard import RemoboardSync
56
+
57
+ with RemoboardSync(host="192.168.1.20", pin="482103") as rb:
58
+ rb.type("Hello 世界")
59
+ rb.enter()
60
+ ```
61
+
62
+ ## API
63
+
64
+ ### `RemoboardClient(host, pin, port=7777, pair_timeout=8.0, on_context=..., on_quickwords=..., on_clipboard=..., on_info=...)`
65
+
66
+ Async client; also an async context manager (`async with`).
67
+
68
+ - `await connect()` — open + pair. Raises `PairingError` on a wrong PIN.
69
+ - `await type(text)` — insert text at the cursor (any Unicode).
70
+ - `await enter()` / `await backspace()`
71
+ - `await move("left"|"right"|"up"|"down")`
72
+ - `await set_clipboard(text)` / `await get_clipboard() -> str`
73
+ - `await set_quick_words(list)` / `await handoff(text)` / `await ping()`
74
+ - `await close()`
75
+
76
+ The optional `on_*` callbacks fire from the receive loop:
77
+ `on_context(before, after)`, `on_quickwords(list)`, `on_clipboard(text)`, `on_info(text)`.
78
+
79
+ ### `RemoboardSync(host, pin, port=7777, **kwargs)`
80
+
81
+ Blocking wrapper backed by a private event loop and a context manager (`with`). Same
82
+ methods without `await` (`get_clipboard()` returns the string directly).
83
+
84
+ ## Notes
85
+
86
+ - The phone and your machine must be on the same network and able to reach each other.
87
+ - The server drops a connection after 5 wrong PINs.
88
+ - The protocol is also implemented by the [Node client](../node) and the
89
+ [MCP server](../mcp).
90
+
91
+ ## License
92
+
93
+ MIT.
@@ -0,0 +1,6 @@
1
+ remoboard/__init__.py,sha256=qRBbGCjgZj2y-485ABgnp1Mp-WEaPkC8CZCfqoszHO0,905
2
+ remoboard/client.py,sha256=SZ-mK-yrPmzMxQ4_qzu1OK0SM_44TqCQWNG4Ch3O-SI,9411
3
+ remoboard-1.0.0.dist-info/METADATA,sha256=Bw0AcPfMw2hAdKBabX9oEy1elCJOuDp72SZsF6Hpo5Q,2766
4
+ remoboard-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
5
+ remoboard-1.0.0.dist-info/licenses/LICENSE,sha256=H258V7WZeGdoEHV2lltTrVRSojQ9_r2FNCrVq1rGsMs,1066
6
+ remoboard-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 Remoboard
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 all
13
+ 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 BUT NOT LIMITED TO 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 THE
21
+ SOFTWARE.