picokvm-client 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.
- picokvm_client/__init__.py +38 -0
- picokvm_client/_cli.py +386 -0
- picokvm_client/_hid.py +259 -0
- picokvm_client/client.py +500 -0
- picokvm_client/exceptions.py +131 -0
- picokvm_client/methods.py +88 -0
- picokvm_client/py.typed +0 -0
- picokvm_client-0.1.0.dist-info/METADATA +177 -0
- picokvm_client-0.1.0.dist-info/RECORD +12 -0
- picokvm_client-0.1.0.dist-info/WHEEL +4 -0
- picokvm_client-0.1.0.dist-info/entry_points.txt +2 -0
- picokvm_client-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Python client for the Luckfox PicoKVM device.
|
|
2
|
+
|
|
3
|
+
The PicoKVM exposes a JSON-RPC 2.0 API at ``POST /api/rpc`` (the same
|
|
4
|
+
methods the web UI drives) plus a session-cookie auth endpoint at
|
|
5
|
+
``POST /auth/login-local``. This package is a thin, synchronous client
|
|
6
|
+
on top of :mod:`httpx` and :mod:`jsonrpcclient` covering both.
|
|
7
|
+
|
|
8
|
+
The PicoKVM firmware is a Luckfox-Pico-based fork of `JetKVM
|
|
9
|
+
<https://github.com/jetkvm/kvm>`_; the wire protocol is largely shared
|
|
10
|
+
with upstream and other forks today, but **this client makes no
|
|
11
|
+
compatibility commitment** to JetKVM or its other forks. The PicoKVM
|
|
12
|
+
is the only device this library is tested against. If you have a
|
|
13
|
+
JetKVM, this library *might* work for the methods that haven't
|
|
14
|
+
diverged — but a separate ``jetkvm-client`` package would be the right
|
|
15
|
+
home for that contract.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from picokvm_client.client import PicoKVMClient
|
|
21
|
+
from picokvm_client.exceptions import (
|
|
22
|
+
AuthError,
|
|
23
|
+
PicoKVMError,
|
|
24
|
+
RpcError,
|
|
25
|
+
TransportError,
|
|
26
|
+
)
|
|
27
|
+
from picokvm_client.methods import KeyboardLedState, UsbState, VideoState
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AuthError",
|
|
31
|
+
"KeyboardLedState",
|
|
32
|
+
"PicoKVMClient",
|
|
33
|
+
"PicoKVMError",
|
|
34
|
+
"RpcError",
|
|
35
|
+
"TransportError",
|
|
36
|
+
"UsbState",
|
|
37
|
+
"VideoState",
|
|
38
|
+
]
|
picokvm_client/_cli.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Bundled ``picokvm`` CLI tool.
|
|
2
|
+
|
|
3
|
+
A thin Typer wrapper around :class:`PicoKVMClient` so users can
|
|
4
|
+
exercise the typed surface from the shell without writing a script.
|
|
5
|
+
|
|
6
|
+
The CLI is intentionally minimal: every verb delegates to a single
|
|
7
|
+
typed method (or to :meth:`PicoKVMClient.rpc` for the ``rpc``
|
|
8
|
+
escape-hatch verb). The package itself is the contract; this module
|
|
9
|
+
just gives users a fast feedback loop.
|
|
10
|
+
|
|
11
|
+
Connection details come from ``--url`` / ``--password`` flags or the
|
|
12
|
+
``PICOKVM_URL`` / ``PICOKVM_PASSWORD`` environment variables (flags
|
|
13
|
+
win). Missing connection info exits non-zero with a clear message --
|
|
14
|
+
no interactive prompts (the CLI is meant to be scriptable).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Annotated, Any
|
|
22
|
+
|
|
23
|
+
import typer
|
|
24
|
+
|
|
25
|
+
from picokvm_client.client import (
|
|
26
|
+
PicoKVMClient,
|
|
27
|
+
VirtualMediaMode,
|
|
28
|
+
)
|
|
29
|
+
from picokvm_client.exceptions import (
|
|
30
|
+
AuthError,
|
|
31
|
+
PicoKVMError,
|
|
32
|
+
RpcError,
|
|
33
|
+
TransportError,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
ENV_URL = "PICOKVM_URL"
|
|
37
|
+
ENV_PASSWORD = "PICOKVM_PASSWORD"
|
|
38
|
+
|
|
39
|
+
app = typer.Typer(
|
|
40
|
+
name="picokvm",
|
|
41
|
+
help=(
|
|
42
|
+
"Drive a Luckfox PicoKVM from the shell.\n\n"
|
|
43
|
+
"Connection details come from --url / --password or the\n"
|
|
44
|
+
f"{ENV_URL} / {ENV_PASSWORD} environment variables (flags win).\n\n"
|
|
45
|
+
"See the package README for the full method list."
|
|
46
|
+
),
|
|
47
|
+
add_completion=False,
|
|
48
|
+
no_args_is_help=True,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Shared option types
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
UrlOpt = Annotated[
|
|
56
|
+
str | None,
|
|
57
|
+
typer.Option(
|
|
58
|
+
"--url",
|
|
59
|
+
"-u",
|
|
60
|
+
envvar=ENV_URL,
|
|
61
|
+
help=f"PicoKVM base URL (or set {ENV_URL}).",
|
|
62
|
+
show_default=False,
|
|
63
|
+
),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
PasswordOpt = Annotated[
|
|
67
|
+
str | None,
|
|
68
|
+
typer.Option(
|
|
69
|
+
"--password",
|
|
70
|
+
"-p",
|
|
71
|
+
envvar=ENV_PASSWORD,
|
|
72
|
+
help=f"PicoKVM login password (or set {ENV_PASSWORD}).",
|
|
73
|
+
show_default=False,
|
|
74
|
+
),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Connection / error helpers
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
def _resolve_url(url: str | None) -> str:
|
|
82
|
+
"""Return ``url`` or fail loudly if missing.
|
|
83
|
+
|
|
84
|
+
A bare connect to ``localhost:30080`` would only confuse a user who
|
|
85
|
+
forgot to set ``PICOKVM_URL``; we'd rather raise.
|
|
86
|
+
"""
|
|
87
|
+
if url is None or url == "":
|
|
88
|
+
typer.echo(
|
|
89
|
+
f"error: no PicoKVM URL provided (--url or {ENV_URL})",
|
|
90
|
+
err=True,
|
|
91
|
+
)
|
|
92
|
+
raise typer.Exit(code=2)
|
|
93
|
+
return url
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _open_client(url: str | None, password: str | None) -> PicoKVMClient:
|
|
97
|
+
resolved = _resolve_url(url)
|
|
98
|
+
return PicoKVMClient(resolved, password=password)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _run(
|
|
102
|
+
url: str | None,
|
|
103
|
+
password: str | None,
|
|
104
|
+
fn: Any,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Open a client, call ``fn(client)``, render PicoKVM errors nicely.
|
|
107
|
+
|
|
108
|
+
Concentrates the try/except so each verb stays a one-liner.
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
with _open_client(url, password) as client:
|
|
112
|
+
fn(client)
|
|
113
|
+
except AuthError as exc:
|
|
114
|
+
typer.echo(f"auth error: {exc}", err=True)
|
|
115
|
+
if exc.hint:
|
|
116
|
+
typer.echo(f" hint: {exc.hint}", err=True)
|
|
117
|
+
raise typer.Exit(code=exc.exit_code) from exc
|
|
118
|
+
except RpcError as exc:
|
|
119
|
+
typer.echo(f"rpc error: {exc}", err=True)
|
|
120
|
+
if exc.friendly_hint:
|
|
121
|
+
typer.echo(f" hint: {exc.friendly_hint}", err=True)
|
|
122
|
+
raise typer.Exit(code=exc.exit_code) from exc
|
|
123
|
+
except TransportError as exc:
|
|
124
|
+
typer.echo(f"transport error: {exc}", err=True)
|
|
125
|
+
if exc.hint:
|
|
126
|
+
typer.echo(f" hint: {exc.hint}", err=True)
|
|
127
|
+
raise typer.Exit(code=exc.exit_code) from exc
|
|
128
|
+
except PicoKVMError as exc:
|
|
129
|
+
typer.echo(f"error: {exc}", err=True)
|
|
130
|
+
raise typer.Exit(code=exc.exit_code) from exc
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# Verbs -- status
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
@app.command("ping")
|
|
137
|
+
def cmd_ping(url: UrlOpt = None, password: PasswordOpt = None) -> None:
|
|
138
|
+
"""Check that the firmware is responding.
|
|
139
|
+
|
|
140
|
+
Exits 0 on ``pong``, 1 otherwise.
|
|
141
|
+
"""
|
|
142
|
+
resolved = _resolve_url(url)
|
|
143
|
+
try:
|
|
144
|
+
with PicoKVMClient(resolved, password=password) as client:
|
|
145
|
+
ok = client.ping()
|
|
146
|
+
except PicoKVMError as exc:
|
|
147
|
+
typer.echo(f"error: {exc}", err=True)
|
|
148
|
+
raise typer.Exit(code=exc.exit_code) from exc
|
|
149
|
+
|
|
150
|
+
if ok:
|
|
151
|
+
typer.echo("pong")
|
|
152
|
+
raise typer.Exit(code=0)
|
|
153
|
+
typer.echo("no pong", err=True)
|
|
154
|
+
raise typer.Exit(code=1)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@app.command("device-id")
|
|
158
|
+
def cmd_device_id(url: UrlOpt = None, password: PasswordOpt = None) -> None:
|
|
159
|
+
"""Print the device ID (one line)."""
|
|
160
|
+
_run(url, password, lambda c: typer.echo(c.get_device_id()))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command("video-state")
|
|
164
|
+
def cmd_video_state(url: UrlOpt = None, password: PasswordOpt = None) -> None:
|
|
165
|
+
"""Print video input state in a human-readable single line."""
|
|
166
|
+
|
|
167
|
+
def _do(c: PicoKVMClient) -> None:
|
|
168
|
+
s = c.get_video_state()
|
|
169
|
+
typer.echo(f"{s.width}x{s.height} @ {s.fps:g}fps ready={str(s.ready).lower()} error={s.error!r}")
|
|
170
|
+
|
|
171
|
+
_run(url, password, _do)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@app.command("usb-state")
|
|
175
|
+
def cmd_usb_state(url: UrlOpt = None, password: PasswordOpt = None) -> None:
|
|
176
|
+
"""Print USB gadget state."""
|
|
177
|
+
_run(
|
|
178
|
+
url,
|
|
179
|
+
password,
|
|
180
|
+
lambda c: typer.echo(f"state={c.get_usb_state().state}"),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
# Verbs -- HID input
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
@app.command("type")
|
|
188
|
+
def cmd_type(
|
|
189
|
+
text: Annotated[str, typer.Argument(help="Text to type into the host.")],
|
|
190
|
+
url: UrlOpt = None,
|
|
191
|
+
password: PasswordOpt = None,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Type a string (US ASCII only)."""
|
|
194
|
+
_run(url, password, lambda c: c.type_text(text))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@app.command("key")
|
|
198
|
+
def cmd_key(
|
|
199
|
+
key: Annotated[str, typer.Argument(help='Named key, e.g. "F2", "Enter".')],
|
|
200
|
+
url: UrlOpt = None,
|
|
201
|
+
password: PasswordOpt = None,
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Press and release a single named key."""
|
|
204
|
+
_run(url, password, lambda c: c.key_press(key))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@app.command("combo")
|
|
208
|
+
def cmd_combo(
|
|
209
|
+
combo: Annotated[
|
|
210
|
+
str,
|
|
211
|
+
typer.Argument(help='Key combo, e.g. "Ctrl+Alt+Del".'),
|
|
212
|
+
],
|
|
213
|
+
url: UrlOpt = None,
|
|
214
|
+
password: PasswordOpt = None,
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Press a key combination."""
|
|
217
|
+
_run(url, password, lambda c: c.key_combo(combo))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@app.command("click")
|
|
221
|
+
def cmd_click(
|
|
222
|
+
x: Annotated[int, typer.Argument(help="Absolute X coordinate (0..32767).")],
|
|
223
|
+
y: Annotated[int, typer.Argument(help="Absolute Y coordinate (0..32767).")],
|
|
224
|
+
button: Annotated[
|
|
225
|
+
str,
|
|
226
|
+
typer.Option(
|
|
227
|
+
"--button",
|
|
228
|
+
"-b",
|
|
229
|
+
help="Mouse button: left/right/middle.",
|
|
230
|
+
),
|
|
231
|
+
] = "left",
|
|
232
|
+
url: UrlOpt = None,
|
|
233
|
+
password: PasswordOpt = None,
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Click at an absolute screen coordinate."""
|
|
236
|
+
_run(url, password, lambda c: c.click(x, y, button=button))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# Verbs -- device management
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
@app.command("reboot")
|
|
243
|
+
def cmd_reboot(
|
|
244
|
+
force: Annotated[
|
|
245
|
+
bool,
|
|
246
|
+
typer.Option(
|
|
247
|
+
"--force",
|
|
248
|
+
"-f",
|
|
249
|
+
help="Pass -f to the underlying reboot command.",
|
|
250
|
+
),
|
|
251
|
+
] = False,
|
|
252
|
+
url: UrlOpt = None,
|
|
253
|
+
password: PasswordOpt = None,
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Reboot the PicoKVM device itself (not the host)."""
|
|
256
|
+
_run(url, password, lambda c: c.reboot(force=force))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# Verbs -- virtual media
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
@app.command("mount-http")
|
|
263
|
+
def cmd_mount_http(
|
|
264
|
+
url_arg: Annotated[
|
|
265
|
+
str,
|
|
266
|
+
typer.Argument(
|
|
267
|
+
metavar="URL",
|
|
268
|
+
help="HTTP/HTTPS URL the firmware will fetch the image from.",
|
|
269
|
+
),
|
|
270
|
+
],
|
|
271
|
+
mode: Annotated[
|
|
272
|
+
VirtualMediaMode,
|
|
273
|
+
typer.Option(
|
|
274
|
+
"--mode",
|
|
275
|
+
"-m",
|
|
276
|
+
help="cdrom (ISO) or disk (raw image).",
|
|
277
|
+
),
|
|
278
|
+
] = "cdrom",
|
|
279
|
+
url: UrlOpt = None,
|
|
280
|
+
password: PasswordOpt = None,
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Mount a remote image via HTTP."""
|
|
283
|
+
_run(url, password, lambda c: c.mount_with_http(url_arg, mode=mode))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@app.command("unmount")
|
|
287
|
+
def cmd_unmount(url: UrlOpt = None, password: PasswordOpt = None) -> None:
|
|
288
|
+
"""Unmount any currently mounted virtual media."""
|
|
289
|
+
_run(url, password, lambda c: c.unmount_image())
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
# Verbs -- generic JSON-RPC passthrough
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
_MAX_PARAM_DEPTH = 100
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _max_bracket_depth(s: str) -> int:
|
|
299
|
+
"""Largest nesting depth of ``[``/``{`` brackets in ``s``."""
|
|
300
|
+
depth = peak = 0
|
|
301
|
+
for ch in s:
|
|
302
|
+
if ch in "[{":
|
|
303
|
+
depth += 1
|
|
304
|
+
peak = max(peak, depth)
|
|
305
|
+
elif ch in "]}":
|
|
306
|
+
depth -= 1
|
|
307
|
+
return peak
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _parse_param(spec: str) -> tuple[str, Any]:
|
|
311
|
+
"""Parse a ``key=value`` --param token.
|
|
312
|
+
|
|
313
|
+
Values are JSON-decoded when possible (so ``force=true`` becomes
|
|
314
|
+
the bool ``True`` and ``keys=[4,5]`` becomes ``[4, 5]``); fall
|
|
315
|
+
back to the raw string when JSON parsing fails (so users do not
|
|
316
|
+
have to quote ordinary strings).
|
|
317
|
+
"""
|
|
318
|
+
if "=" not in spec:
|
|
319
|
+
raise typer.BadParameter(
|
|
320
|
+
f"--param expects key=value, got {spec!r}",
|
|
321
|
+
)
|
|
322
|
+
key, _, raw = spec.partition("=")
|
|
323
|
+
if not key:
|
|
324
|
+
raise typer.BadParameter(f"--param key cannot be empty: {spec!r}")
|
|
325
|
+
# Reject pathologically nested input deterministically instead of relying
|
|
326
|
+
# on json.loads raising RecursionError -- json's recursion behaviour
|
|
327
|
+
# differs across Python versions (e.g. 3.13 parses far deeper). Anything
|
|
328
|
+
# past a sane depth is not a real parameter; treat it as a plain string.
|
|
329
|
+
if _max_bracket_depth(raw) > _MAX_PARAM_DEPTH:
|
|
330
|
+
return key, raw
|
|
331
|
+
try:
|
|
332
|
+
value: Any = json.loads(raw)
|
|
333
|
+
except (json.JSONDecodeError, RecursionError, ValueError):
|
|
334
|
+
# ``json.loads`` raises ``RecursionError`` on deeply nested
|
|
335
|
+
# input (e.g. ``[[[[...]]]]`` past the interpreter's recursion
|
|
336
|
+
# limit) and ``ValueError`` for a handful of edge cases (NaN /
|
|
337
|
+
# Infinity in strict mode, surrogate handling). Treat all of
|
|
338
|
+
# these as "not valid JSON, fall back to the raw string" so the
|
|
339
|
+
# CLI never escapes with a stack trace.
|
|
340
|
+
value = raw
|
|
341
|
+
return key, value
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@app.command("rpc")
|
|
345
|
+
def cmd_rpc(
|
|
346
|
+
method: Annotated[
|
|
347
|
+
str,
|
|
348
|
+
typer.Argument(help="JSON-RPC method name, e.g. 'getJigglerState'."),
|
|
349
|
+
],
|
|
350
|
+
params: Annotated[
|
|
351
|
+
list[str] | None,
|
|
352
|
+
typer.Option(
|
|
353
|
+
"--param",
|
|
354
|
+
help=("key=value pair (repeatable). Values are JSON-decoded when possible; otherwise treated as strings."),
|
|
355
|
+
),
|
|
356
|
+
] = None,
|
|
357
|
+
url: UrlOpt = None,
|
|
358
|
+
password: PasswordOpt = None,
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Generic JSON-RPC passthrough: call any firmware method.
|
|
361
|
+
|
|
362
|
+
The result is printed as JSON on stdout. Useful for the long tail
|
|
363
|
+
of methods that don't have a typed wrapper yet.
|
|
364
|
+
"""
|
|
365
|
+
parsed: dict[str, Any] = {}
|
|
366
|
+
for spec in params or []:
|
|
367
|
+
key, value = _parse_param(spec)
|
|
368
|
+
parsed[key] = value
|
|
369
|
+
|
|
370
|
+
def _do(c: PicoKVMClient) -> None:
|
|
371
|
+
result = c.rpc(method, **parsed)
|
|
372
|
+
json.dump(result, sys.stdout, indent=2, default=str)
|
|
373
|
+
sys.stdout.write("\n")
|
|
374
|
+
|
|
375
|
+
_run(url, password, _do)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# Module entry point (so `python -m picokvm_client._cli` works too)
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
def main() -> None: # pragma: no cover - thin entry point
|
|
382
|
+
app()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
if __name__ == "__main__": # pragma: no cover
|
|
386
|
+
main()
|
picokvm_client/_hid.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""USB HID lookup tables for the high-level keyboard / mouse helpers.
|
|
2
|
+
|
|
3
|
+
The PicoKVM firmware (``kvm/usb.go``) accepts raw USB HID Boot-Protocol
|
|
4
|
+
reports for both the keyboard and mouse channels. This module owns the
|
|
5
|
+
lookup tables that :meth:`PicoKVMClient.type_text`,
|
|
6
|
+
:meth:`~PicoKVMClient.key_press`, and :meth:`~PicoKVMClient.key_combo`
|
|
7
|
+
use to translate human-friendly Python arguments into the wire bytes:
|
|
8
|
+
|
|
9
|
+
* :data:`KEYNAME_TO_KEYCODE` -- named keys ("Enter", "F2", arrows, ...)
|
|
10
|
+
to USB HID Usage IDs. Source: USB HID Usage Tables 1.22, section 10
|
|
11
|
+
(Keyboard/Keypad Page, ``0x07``). See
|
|
12
|
+
https://usb.org/sites/default/files/hut1_22.pdf .
|
|
13
|
+
|
|
14
|
+
* :data:`ASCII_TO_HID` -- printable US-ASCII characters to a
|
|
15
|
+
``(usage_id, requires_shift)`` tuple. Layout: the standard US
|
|
16
|
+
keyboard. Non-US layouts are out of scope for v0.1.0; pass raw HID
|
|
17
|
+
reports via :meth:`PicoKVMClient.keyboard_report` if you need them.
|
|
18
|
+
|
|
19
|
+
* :data:`MODIFIER_BITS` -- modifier-key name to the bit position in the
|
|
20
|
+
HID modifier byte. Multiple aliases for the GUI bit (``Win``,
|
|
21
|
+
``Meta``, ``Cmd``, ``GUI``) so :meth:`~.key_combo` is forgiving about
|
|
22
|
+
what users type.
|
|
23
|
+
|
|
24
|
+
Lookups are intentionally case-insensitive: we lowercase the key/token
|
|
25
|
+
before consulting the table. The tables themselves store lowercase
|
|
26
|
+
keys.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from typing import Final
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Modifier byte (USB HID keyboard report byte 0)
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Bit layout per the HID Usage Tables, Keyboard/Keypad Page:
|
|
37
|
+
# bit 0: Left Ctrl bit 4: Right Ctrl
|
|
38
|
+
# bit 1: Left Shift bit 5: Right Shift
|
|
39
|
+
# bit 2: Left Alt bit 6: Right Alt
|
|
40
|
+
# bit 3: Left GUI bit 7: Right GUI
|
|
41
|
+
#
|
|
42
|
+
# We expose the *left*-side bit for each name; combos like "Right Ctrl"
|
|
43
|
+
# are uncommon in practice and can be issued via keyboard_report() with
|
|
44
|
+
# a hand-rolled modifier byte.
|
|
45
|
+
MODIFIER_BITS: Final[dict[str, int]] = {
|
|
46
|
+
"ctrl": 0x01,
|
|
47
|
+
"control": 0x01,
|
|
48
|
+
"shift": 0x02,
|
|
49
|
+
"alt": 0x04,
|
|
50
|
+
"option": 0x04, # macOS naming
|
|
51
|
+
# GUI bit aliases. All map to the same bit (Left GUI, 0x08).
|
|
52
|
+
"win": 0x08,
|
|
53
|
+
"meta": 0x08,
|
|
54
|
+
"cmd": 0x08,
|
|
55
|
+
"command": 0x08,
|
|
56
|
+
"gui": 0x08,
|
|
57
|
+
"super": 0x08,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Named keys -> USB HID Usage IDs (keyboard/keypad page, 0x07)
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Lowercase keys for case-insensitive lookup. Names follow common
|
|
65
|
+
# conventions ("Enter" not "Return", "Esc" with "Escape" as alias,
|
|
66
|
+
# arrows as "ArrowUp" / "Up", etc.).
|
|
67
|
+
KEYNAME_TO_KEYCODE: Final[dict[str, int]] = {
|
|
68
|
+
# Letters (HID 0x04..0x1D)
|
|
69
|
+
"a": 0x04,
|
|
70
|
+
"b": 0x05,
|
|
71
|
+
"c": 0x06,
|
|
72
|
+
"d": 0x07,
|
|
73
|
+
"e": 0x08,
|
|
74
|
+
"f": 0x09,
|
|
75
|
+
"g": 0x0A,
|
|
76
|
+
"h": 0x0B,
|
|
77
|
+
"i": 0x0C,
|
|
78
|
+
"j": 0x0D,
|
|
79
|
+
"k": 0x0E,
|
|
80
|
+
"l": 0x0F,
|
|
81
|
+
"m": 0x10,
|
|
82
|
+
"n": 0x11,
|
|
83
|
+
"o": 0x12,
|
|
84
|
+
"p": 0x13,
|
|
85
|
+
"q": 0x14,
|
|
86
|
+
"r": 0x15,
|
|
87
|
+
"s": 0x16,
|
|
88
|
+
"t": 0x17,
|
|
89
|
+
"u": 0x18,
|
|
90
|
+
"v": 0x19,
|
|
91
|
+
"w": 0x1A,
|
|
92
|
+
"x": 0x1B,
|
|
93
|
+
"y": 0x1C,
|
|
94
|
+
"z": 0x1D,
|
|
95
|
+
# Digit row (HID 0x1E..0x27). "1".."0" -- note 0 is at the end.
|
|
96
|
+
"1": 0x1E,
|
|
97
|
+
"2": 0x1F,
|
|
98
|
+
"3": 0x20,
|
|
99
|
+
"4": 0x21,
|
|
100
|
+
"5": 0x22,
|
|
101
|
+
"6": 0x23,
|
|
102
|
+
"7": 0x24,
|
|
103
|
+
"8": 0x25,
|
|
104
|
+
"9": 0x26,
|
|
105
|
+
"0": 0x27,
|
|
106
|
+
# Whitespace / control
|
|
107
|
+
"enter": 0x28,
|
|
108
|
+
"return": 0x28,
|
|
109
|
+
"esc": 0x29,
|
|
110
|
+
"escape": 0x29,
|
|
111
|
+
"backspace": 0x2A,
|
|
112
|
+
"tab": 0x2B,
|
|
113
|
+
"space": 0x2C,
|
|
114
|
+
" ": 0x2C,
|
|
115
|
+
# Punctuation row (matches ASCII_TO_HID printable mappings)
|
|
116
|
+
"-": 0x2D,
|
|
117
|
+
"minus": 0x2D,
|
|
118
|
+
"=": 0x2E,
|
|
119
|
+
"equal": 0x2E,
|
|
120
|
+
"equals": 0x2E,
|
|
121
|
+
"[": 0x2F,
|
|
122
|
+
"leftbracket": 0x2F,
|
|
123
|
+
"]": 0x30,
|
|
124
|
+
"rightbracket": 0x30,
|
|
125
|
+
"\\": 0x31,
|
|
126
|
+
"backslash": 0x31,
|
|
127
|
+
";": 0x33,
|
|
128
|
+
"semicolon": 0x33,
|
|
129
|
+
"'": 0x34,
|
|
130
|
+
"apostrophe": 0x34,
|
|
131
|
+
"quote": 0x34,
|
|
132
|
+
"`": 0x35,
|
|
133
|
+
"grave": 0x35,
|
|
134
|
+
"backtick": 0x35,
|
|
135
|
+
",": 0x36,
|
|
136
|
+
"comma": 0x36,
|
|
137
|
+
".": 0x37,
|
|
138
|
+
"period": 0x37,
|
|
139
|
+
"dot": 0x37,
|
|
140
|
+
"/": 0x38,
|
|
141
|
+
"slash": 0x38,
|
|
142
|
+
"capslock": 0x39,
|
|
143
|
+
"caps": 0x39,
|
|
144
|
+
# Function keys (HID 0x3A..0x45)
|
|
145
|
+
"f1": 0x3A,
|
|
146
|
+
"f2": 0x3B,
|
|
147
|
+
"f3": 0x3C,
|
|
148
|
+
"f4": 0x3D,
|
|
149
|
+
"f5": 0x3E,
|
|
150
|
+
"f6": 0x3F,
|
|
151
|
+
"f7": 0x40,
|
|
152
|
+
"f8": 0x41,
|
|
153
|
+
"f9": 0x42,
|
|
154
|
+
"f10": 0x43,
|
|
155
|
+
"f11": 0x44,
|
|
156
|
+
"f12": 0x45,
|
|
157
|
+
# Navigation cluster
|
|
158
|
+
"printscreen": 0x46,
|
|
159
|
+
"prtsc": 0x46,
|
|
160
|
+
"scrolllock": 0x47,
|
|
161
|
+
"pause": 0x48,
|
|
162
|
+
"break": 0x48,
|
|
163
|
+
"insert": 0x49,
|
|
164
|
+
"ins": 0x49,
|
|
165
|
+
"home": 0x4A,
|
|
166
|
+
"pageup": 0x4B,
|
|
167
|
+
"pgup": 0x4B,
|
|
168
|
+
"delete": 0x4C,
|
|
169
|
+
"del": 0x4C,
|
|
170
|
+
"end": 0x4D,
|
|
171
|
+
"pagedown": 0x4E,
|
|
172
|
+
"pgdn": 0x4E,
|
|
173
|
+
# Arrow keys
|
|
174
|
+
"right": 0x4F,
|
|
175
|
+
"arrowright": 0x4F,
|
|
176
|
+
"left": 0x50,
|
|
177
|
+
"arrowleft": 0x50,
|
|
178
|
+
"down": 0x51,
|
|
179
|
+
"arrowdown": 0x51,
|
|
180
|
+
"up": 0x52,
|
|
181
|
+
"arrowup": 0x52,
|
|
182
|
+
# Application keys
|
|
183
|
+
"menu": 0x65,
|
|
184
|
+
"app": 0x65,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Printable US-ASCII -> (usage_id, requires_shift)
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Built mechanically from KEYNAME_TO_KEYCODE for the unshifted side and
|
|
192
|
+
# from the standard US shift map for the shifted side. Whitespace is
|
|
193
|
+
# handled here (space, tab, newline) so type_text() can iterate over a
|
|
194
|
+
# single table.
|
|
195
|
+
def _build_ascii_to_hid() -> dict[str, tuple[int, bool]]:
|
|
196
|
+
table: dict[str, tuple[int, bool]] = {}
|
|
197
|
+
|
|
198
|
+
# Letters: lowercase unshifted, uppercase shifted, both share keycode.
|
|
199
|
+
for ch_low in "abcdefghijklmnopqrstuvwxyz":
|
|
200
|
+
keycode = KEYNAME_TO_KEYCODE[ch_low]
|
|
201
|
+
table[ch_low] = (keycode, False)
|
|
202
|
+
table[ch_low.upper()] = (keycode, True)
|
|
203
|
+
|
|
204
|
+
# Digit row: unshifted digits and their shifted symbols.
|
|
205
|
+
digit_shifted = {
|
|
206
|
+
"1": "!",
|
|
207
|
+
"2": "@",
|
|
208
|
+
"3": "#",
|
|
209
|
+
"4": "$",
|
|
210
|
+
"5": "%",
|
|
211
|
+
"6": "^",
|
|
212
|
+
"7": "&",
|
|
213
|
+
"8": "*",
|
|
214
|
+
"9": "(",
|
|
215
|
+
"0": ")",
|
|
216
|
+
}
|
|
217
|
+
for digit, sym in digit_shifted.items():
|
|
218
|
+
keycode = KEYNAME_TO_KEYCODE[digit]
|
|
219
|
+
table[digit] = (keycode, False)
|
|
220
|
+
table[sym] = (keycode, True)
|
|
221
|
+
|
|
222
|
+
# Punctuation pairs: (unshifted, shifted, keycode).
|
|
223
|
+
# Sourced from the US keyboard layout.
|
|
224
|
+
punct = [
|
|
225
|
+
("-", "_", KEYNAME_TO_KEYCODE["-"]),
|
|
226
|
+
("=", "+", KEYNAME_TO_KEYCODE["="]),
|
|
227
|
+
("[", "{", KEYNAME_TO_KEYCODE["["]),
|
|
228
|
+
("]", "}", KEYNAME_TO_KEYCODE["]"]),
|
|
229
|
+
("\\", "|", KEYNAME_TO_KEYCODE["\\"]),
|
|
230
|
+
(";", ":", KEYNAME_TO_KEYCODE[";"]),
|
|
231
|
+
("'", '"', KEYNAME_TO_KEYCODE["'"]),
|
|
232
|
+
("`", "~", KEYNAME_TO_KEYCODE["`"]),
|
|
233
|
+
(",", "<", KEYNAME_TO_KEYCODE[","]),
|
|
234
|
+
(".", ">", KEYNAME_TO_KEYCODE["."]),
|
|
235
|
+
("/", "?", KEYNAME_TO_KEYCODE["/"]),
|
|
236
|
+
]
|
|
237
|
+
for low, high, keycode in punct:
|
|
238
|
+
table[low] = (keycode, False)
|
|
239
|
+
table[high] = (keycode, True)
|
|
240
|
+
|
|
241
|
+
# Whitespace. Newline -> Enter; tab -> Tab; space -> Space.
|
|
242
|
+
table[" "] = (KEYNAME_TO_KEYCODE["space"], False)
|
|
243
|
+
table["\n"] = (KEYNAME_TO_KEYCODE["enter"], False)
|
|
244
|
+
table["\t"] = (KEYNAME_TO_KEYCODE["tab"], False)
|
|
245
|
+
|
|
246
|
+
return table
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
ASCII_TO_HID: Final[dict[str, tuple[int, bool]]] = _build_ascii_to_hid()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# Mouse button bitmask (HID mouse report byte 0)
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
MOUSE_BUTTON_BITS: Final[dict[str, int]] = {
|
|
256
|
+
"left": 0x01,
|
|
257
|
+
"right": 0x02,
|
|
258
|
+
"middle": 0x04,
|
|
259
|
+
}
|