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.
@@ -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
+ }