elfdeckai 1.0.4__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,230 @@
1
+ Metadata-Version: 2.4
2
+ Name: elfdeckai
3
+ Version: 1.0.4
4
+ Summary: Virtual keyboard, gamepad and mouse for AI agents on Linux / Steam Deck โ€” zero-dependency uinput daemons with an MCP server.
5
+ Author: Garth Elfstrom
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Garthnesss/ElfDeckAI
8
+ Project-URL: Repository, https://github.com/Garthnesss/ElfDeckAI
9
+ Project-URL: Issues, https://github.com/Garthnesss/ElfDeckAI/issues
10
+ Keywords: mcp,model-context-protocol,llm,ai-agent,steam-deck,uinput,gamepad,automation
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Programming Language :: Python :: 3
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # ๐ŸŽฎ ElfdeckAI
20
+
21
+ ### Your AI can *see* the game. Now it can hold the controller.
22
+
23
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
24
+ ![Python](https://img.shields.io/badge/python-3.8+-blue.svg)
25
+ ![Dependencies](https://img.shields.io/badge/dependencies-zero-brightgreen.svg)
26
+ ![Tests](https://img.shields.io/badge/tests-146%20passing-brightgreen.svg)
27
+
28
+ **ElfdeckAI gives an LLM agent hands on real hardware.** Three tiny
29
+ daemons turn one-line commands โ€” from a shell, a script, or native
30
+ **MCP tool calls** โ€” into real keyboard, gamepad and mouse input on a
31
+ Linux machine:
32
+
33
+ - โŒจ๏ธ **ElfKey** โ€” a virtual keyboard on `/dev/uinput`
34
+ - ๐Ÿ•น๏ธ **ElfPad** โ€” a virtual gamepad (Xbox-class: buttons, analog triggers,
35
+ two sticks, D-pad)
36
+ - ๐Ÿ–ฑ๏ธ **ElfMouse** โ€” a virtual mouse (relative pointer, buttons, wheel)
37
+
38
+ Every "LLM plays Pokรฉmon" project you've seen plugs into an **emulator's
39
+ API**. ElfdeckAI presses buttons on a **real device** โ€” proven live when an
40
+ LLM played Pokรฉmon FireRed on a physical Steam Deck end-to-end through
41
+ ElfKey: full intro, starter picked, first rival battle won, game saved.
42
+ No emulator *API*, no RAM reading, no capture card โ€” the game runs on
43
+ the real device (inside RetroArch, in that run); the agent's only
44
+ channel in is real input, and its only view is a stream for eyes.
45
+ ([How it drives](notes/driving.md))
46
+
47
+ ## โšก Run it yourself (60 seconds)
48
+
49
+ ```sh
50
+ pip install git+https://github.com/Garthnesss/ElfDeckAI
51
+
52
+ elfkey & # virtual keyboard (command: elfkey)
53
+ elfpad & # virtual gamepad (command: elfpad)
54
+ elfmouse & # virtual mouse (command: elfmouse)
55
+
56
+ echo "key x 150" > /tmp/elfkey.fifo # tap X
57
+ echo "press a" > /tmp/elfpad.fifo # tap A
58
+ echo "move 300 -100" > /tmp/elfmouse.fifo # flick the pointer
59
+ echo "click left" > /tmp/elfmouse.fifo # click
60
+
61
+ grep -E "ElfKey|ElfPad|ElfMouse" /proc/bus/input/devices # they're really there
62
+ ```
63
+
64
+ That's the whole install. Three single-file Python programs, **standard
65
+ library only** โ€” nothing to build, no dependencies to resolve. State
66
+ lives in `/tmp`; reboot and it's gone. Prefer no pip at all? Clone the
67
+ repo and run `python3 elfkey.py` โ€” same thing.
68
+
69
+ For your agent, the pip install also provides the MCP server as a
70
+ command: `"command": "elfdeckai-mcp"` (details below).
71
+
72
+ > **Steam Deck?** You're already done โ€” `/dev/uinput` is user-writable
73
+ > there out of the box. On other Linux machines, install the included
74
+ > udev rule once (see [Install](#install)).
75
+
76
+ ## ๐Ÿค– Give it to your agent (MCP)
77
+
78
+ `elfdeckai_mcp.py` is a zero-dependency [Model Context
79
+ Protocol](https://modelcontextprotocol.io) server that exposes all
80
+ three devices as 18 native tools โ€” `key_tap`, `pad_press`, `pad_stick`,
81
+ `mouse_move`, `mouse_click`, `pad_reset`, `key_release`, โ€ฆ โ€” for Claude,
82
+ Hermes, or any MCP client:
83
+
84
+ ```json
85
+ {"mcpServers": {"elfdeckai": {"command": "elfdeckai-mcp"}}}
86
+ ```
87
+
88
+ (No pip? `"command": "python3", "args": ["/path/to/elfdeckai_mcp.py"]`
89
+ works identically.)
90
+
91
+ Start the daemons, add that to your client's config, and your agent can
92
+ press buttons the same way it calls any other tool. Every call is
93
+ validated by the daemons' own input contract before it touches a FIFO,
94
+ and a stopped daemon fails the call cleanly instead of hanging your agent.
95
+
96
+ ## ๐ŸŽ›๏ธ Commands
97
+
98
+ ### ElfKey โ€” `/tmp/elfkey.fifo`
99
+
100
+ | Command | Effect |
101
+ |---|---|
102
+ | `key <name> [ms]` | tap a key (default 120 ms) |
103
+ | `down <name>` / `up <name>` | hold / release |
104
+ | `release` | let go of every held key at once (the unstick command) |
105
+ | `quit` | release everything and exit |
106
+
107
+ Keys: `a`โ€“`z`, `0`โ€“`9`, arrows, `enter`, `esc`, `space`, `tab`, `shift`,
108
+ `rshift`, `ctrl`, `alt`, punctuation, `f1`โ€“`f10`, `home`, `end`, `insert`,
109
+ `delete`, โ€ฆ (full table in the source).
110
+
111
+ ### ElfPad โ€” `/tmp/elfpad.fifo`
112
+
113
+ | Command | Effect |
114
+ |---|---|
115
+ | `press <btn> [ms]` | tap a button โ€” or a D-pad direction |
116
+ | `down <btn>` / `up <btn>` | hold / release |
117
+ | `stick left\|right <x> <y> [ms]` | analog stick, โˆ’100..100 % per axis; `ms` = auto-recenter after |
118
+ | `trigger left\|right <0..100> [ms]` | analog trigger; `ms` = auto-release after |
119
+ | `center` | sticks, triggers, D-pad to neutral |
120
+ | `reset` | release everything |
121
+ | `quit` | release everything and exit |
122
+
123
+ Buttons: `a b x y lb rb l2 r2 select start guide l3 r3`. ElfPad presents
124
+ as an Xbox-360-class pad, so SDL, Steam Input and RetroArch map it the
125
+ standard way with zero configuration. `l2`/`r2` are analog-only, exactly
126
+ like the real pad โ€” there are deliberately no digital trigger buttons,
127
+ because declaring them shifts Select/Start/Guide two button indices off
128
+ the stock layout (verified against a real 360 pad on a Deck).
129
+
130
+ ### ElfMouse โ€” `/tmp/elfmouse.fifo`
131
+
132
+ | Command | Effect |
133
+ |---|---|
134
+ | `move <dx> <dy> [glide_ms]` | move the pointer (relative); `glide_ms` = glide there over time |
135
+ | `click <btn> [ms]` | click `left` / `right` / `middle` |
136
+ | `down <btn>` / `up <btn>` | hold / release a button โ€” drags are `down`, `move`, `up` |
137
+ | `scroll <ticks>` / `hscroll <ticks>` | wheel / horizontal wheel (positive = up / right) |
138
+ | `release` | let go of every held button at once (the unstick command) |
139
+ | `quit` | release any held button and exit |
140
+
141
+ Movement is relative, like a real mouse โ€” pair it with screenshots and
142
+ the same look โ†’ move โ†’ verify loop the rest of the set uses. Sign
143
+ convention: negative `dy` is up, positive is down. Units are relative
144
+ counts, not guaranteed screen pixels โ€” pointer acceleration stretches
145
+ fast moves โ€” so small moves plus a fresh screenshot beat one big leap. Per-command
146
+ bounds (ยฑ10 000 px, ยฑ120 ticks) keep a confused call from flinging the
147
+ pointer into orbit; chain commands for long travel.
148
+
149
+ ## ๐Ÿ›ก๏ธ Built for callers that hallucinate
150
+
151
+ Letting a language model press buttons is safe only if the keyboard
152
+ itself can't be talked into a bad state. So:
153
+
154
+ - **Bad input never kills the daemon.** Every line passes a strict
155
+ parser; malformed commands are logged and refused. (`key x abc` and
156
+ `key x 999999999` were real crash/wedge bugs in the first version โ€”
157
+ the tests now pin them shut.)
158
+ - **Holds are bounded.** A tap is at most 5 s. Longer holds use
159
+ `down`/`up`, which the caller controls.
160
+ - **Nothing gets stuck.** Timed moves restore in a `finally` block; held
161
+ inputs are tracked; on `quit`, SIGTERM or SIGINT, everything is
162
+ released and recentered before the device is destroyed.
163
+ - **The FIFO is owner-only (0600).** Only your user can inject input.
164
+ - **The server survives garbage.** Malformed JSON and non-object
165
+ requests get proper JSON-RPC protocol errors; one bad line โ€” or a
166
+ caller dying mid-hold โ€” can't wedge the session. `key_release`,
167
+ `mouse_release` and `pad_reset` are the unstick buttons โ€” a release
168
+ queues behind at most the one hold currently executing (bounded,
169
+ โ‰ค 5 s), and every accepted command is logged to stderr, so what the
170
+ agent pressed is auditable afterwards.
171
+ - **Scope honesty:** these are real OS-level input devices. They type
172
+ and click in whatever window has focus โ€” a game, but equally a
173
+ terminal or a browser. ElfdeckAI makes that input *un-mess-up-able*
174
+ (bounded, validated, releasable); deciding *where* an agent may act
175
+ is the operator's call. Run agents you trust, on sessions you'd
176
+ watch.
177
+
178
+ ## ๐Ÿค” Why notโ€ฆ?
179
+
180
+ | Approach | Why it falls short |
181
+ |---|---|
182
+ | Emulator APIs (PyBoy, mGBA scripting) | Only exists inside an emulator. Your Deck is not an emulator. |
183
+ | Sending keys through Moonlight/streaming | Clicks get forwarded; synthetic keyboard events don't (verified). |
184
+ | xdotool / desktop automation | Can't reach native Wayland clients. |
185
+ | ydotool / dotool | The real incumbents โ€” same uinput trick, keyboard + mouse. But: raw keycodes, a root daemon, no gamepad, and nothing agent-facing (no MCP, no safety bounds, no unstick). |
186
+ | KVM gadget / controller-spoofing hardware | Works, but it's extra hardware on your desk doing what 200 lines of Python do on the device itself. |
187
+
188
+ ## Install
189
+
190
+ **Requirements:** Linux (64-bit) with `/dev/uinput`, Python 3.8+. Nothing else.
191
+
192
+ Anywhere `/dev/uinput` isn't user-writable, install the udev rule once:
193
+
194
+ ```sh
195
+ sudo cp 70-elfdeckai-uinput.rules /etc/udev/rules.d/
196
+ sudo udevadm control --reload-rules && sudo udevadm trigger
197
+ getfacl /dev/uinput # verify: your user should appear in the ACL
198
+ ```
199
+
200
+ Run persistently as user services (expects the repo at `~/elfdeckai/` โ€” clone with `git clone https://github.com/Garthnesss/ElfDeckAI ~/elfdeckai`):
201
+
202
+ ```sh
203
+ cp systemd/*.service ~/.config/systemd/user/
204
+ systemctl --user enable --now elfkey.service elfpad.service elfmouse.service
205
+ ```
206
+
207
+ ## Tests
208
+
209
+ ```sh
210
+ python3 -m pytest # 146 tests: all three input contracts, device behaviour
211
+ # against a fake uinput, and the MCP server spoken
212
+ # to over real JSON-RPC
213
+ ```
214
+
215
+ ## Roadmap
216
+
217
+ - One combined daemon (keyboard + pad + mouse in a single process).
218
+ - PyPI release, so `pip install elfdeckai` works without the git URL.
219
+ - Demo clip: the Pokรฉmon run, on camera.
220
+
221
+ ## License
222
+
223
+ MIT โ€” see [LICENSE](LICENSE). Steal it, ship it, teach your agent to
224
+ speedrun something.
225
+
226
+ ## Author
227
+
228
+ Created and maintained by **Garth Elfstrom**
229
+ ([@Garthnesss](https://github.com/Garthnesss)). Issues and PRs welcome
230
+ on the repo.
@@ -0,0 +1,10 @@
1
+ elfdeckai_mcp.py,sha256=Lm1OX_FUD2a6XFivaqpD9czvs4lHn18_V3bL8ilS0NI,15168
2
+ elfkey.py,sha256=r1hSMZPLScKD5Liz4SCEe6T6pWW7RWz8h48xuyqdWG8,9895
3
+ elfmouse.py,sha256=ePRELR6Hd24-0Zb7bluK5sh3fUE5y9AOZH4Qza01bpY,11422
4
+ elfpad.py,sha256=KOEb00vxuRdMwWb3qB-8AY7au4YpUc5LzohmfHWYVeA,14980
5
+ elfdeckai-1.0.4.dist-info/licenses/LICENSE,sha256=hfa3D3Ptj_JPKQmCAlOx0A8hRtvFcuUQSELmhEfrkss,1071
6
+ elfdeckai-1.0.4.dist-info/METADATA,sha256=udO1DTB3lS8UEb2NAjtSY4prITIyH0WwiT5gPsJoHJI,10149
7
+ elfdeckai-1.0.4.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ elfdeckai-1.0.4.dist-info/entry_points.txt,sha256=bNwHpIIUX75G1BZ9-b03vaOZUOpDe-_xBFZLmPDdMrQ,120
9
+ elfdeckai-1.0.4.dist-info/top_level.txt,sha256=gGmh3_yCJqjRyKb3hGn-vq37hiC5xPoCebWzMLTPEtE,37
10
+ elfdeckai-1.0.4.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ elfdeckai-mcp = elfdeckai_mcp:main
3
+ elfkey = elfkey:main
4
+ elfmouse = elfmouse:main
5
+ elfpad = elfpad:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Garth Elfstrom
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.
@@ -0,0 +1,4 @@
1
+ elfdeckai_mcp
2
+ elfkey
3
+ elfmouse
4
+ elfpad
elfdeckai_mcp.py ADDED
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2026 Garth Elfstrom โ€” MIT License (see LICENSE).
3
+ """ElfdeckAI as MCP tools: a standard-library-only Model Context Protocol
4
+ server (stdio, JSON-RPC) that exposes ElfKey and ElfPad to any MCP client โ€”
5
+ Claude, Hermes, your own agent โ€” as native tool calls:
6
+
7
+ key_tap {key, hold_ms?} pad_press {button, hold_ms?}
8
+ key_down {key} pad_down {button}
9
+ key_up {key} pad_up {button}
10
+ mouse_move {dx, dy, glide_ms?} pad_stick {side, x, y, hold_ms?}
11
+ mouse_click {button, hold_ms?} pad_trigger {side, percent, hold_ms?}
12
+ mouse_down {button} pad_center {}
13
+ mouse_up {button} pad_reset {}
14
+ mouse_scroll {clicks} key_release {} (unstick everything)
15
+ mouse_hscroll {clicks} mouse_release {} (unstick everything)
16
+
17
+ The server holds no state and injects nothing itself: every call is
18
+ validated by the daemons' own parse_command() (the exact contract the
19
+ FIFOs enforce) and then written as one line to the daemon's FIFO. If a
20
+ daemon isn't running, the call fails cleanly with "daemon not running"
21
+ instead of blocking โ€” opening a FIFO for write with no reader would
22
+ otherwise hang the agent.
23
+
24
+ Run the daemons first (elfkey.py, elfpad.py, elfmouse.py), then point your MCP client
25
+ at this file, e.g.:
26
+
27
+ {"mcpServers": {"elfdeckai": {
28
+ "command": "python3", "args": ["/path/to/elfdeckai_mcp.py"]}}}
29
+
30
+ FIFO paths default to the daemons' own constants and can be overridden
31
+ with ELFKEY_FIFO / ELFPAD_FIFO environment variables (used by the tests).
32
+ Standard library only, like everything else in this repo.
33
+ """
34
+ import errno
35
+ import json
36
+ import os
37
+ import sys
38
+
39
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
40
+ import elfkey
41
+ import elfmouse
42
+ import elfpad
43
+
44
+ KEY_FIFO = os.environ.get("ELFKEY_FIFO", elfkey.FIFO)
45
+ PAD_FIFO = os.environ.get("ELFPAD_FIFO", elfpad.FIFO)
46
+ MOUSE_FIFO = os.environ.get("ELFMOUSE_FIFO", elfmouse.FIFO)
47
+ PROTOCOL_VERSION = "2024-11-05"
48
+
49
+
50
+ def _send(fifo, line, parser):
51
+ """Validate `line` with the daemon's parser, then hand it to the FIFO.
52
+ Returns a human-readable confirmation or raises ValueError/OSError."""
53
+ parser(line) # raises ValueError on anything the daemon would refuse
54
+ payload = (line + "\n").encode()
55
+ fd = os.open(fifo, os.O_WRONLY | os.O_NONBLOCK)
56
+ try:
57
+ if os.write(fd, payload) != len(payload):
58
+ raise OSError(errno.EIO, "short FIFO write")
59
+ finally:
60
+ os.close(fd)
61
+ return "sent: %s" % line
62
+
63
+
64
+ def _key_line(cmd, a):
65
+ if cmd == "key_release":
66
+ return "release"
67
+ if cmd == "key_tap":
68
+ ms = a.get("hold_ms")
69
+ return "key %s%s" % (a["key"], "" if ms is None else " %s" % ms)
70
+ return "%s %s" % (cmd.replace("key_", ""), a["key"]) # key_down / key_up
71
+
72
+
73
+ def _pad_line(cmd, a):
74
+ if cmd == "pad_press":
75
+ ms = a.get("hold_ms")
76
+ return "press %s%s" % (a["button"], "" if ms is None else " %s" % ms)
77
+ if cmd in ("pad_down", "pad_up"):
78
+ return "%s %s" % (cmd.replace("pad_", ""), a["button"])
79
+ if cmd == "pad_stick":
80
+ ms = a.get("hold_ms")
81
+ return "stick %s %s %s%s" % (a["side"], a["x"], a["y"],
82
+ "" if ms is None else " %s" % ms)
83
+ if cmd == "pad_trigger":
84
+ ms = a.get("hold_ms")
85
+ return "trigger %s %s%s" % (a["side"], a["percent"],
86
+ "" if ms is None else " %s" % ms)
87
+ return cmd.replace("pad_", "") # pad_center / pad_reset
88
+
89
+
90
+ def _mouse_line(cmd, a):
91
+ if cmd == "mouse_release":
92
+ return "release"
93
+ if cmd == "mouse_move":
94
+ ms = a.get("glide_ms")
95
+ return "move %s %s%s" % (a["dx"], a["dy"],
96
+ "" if ms is None else " %s" % ms)
97
+ if cmd == "mouse_click":
98
+ ms = a.get("hold_ms")
99
+ return "click %s%s" % (a["button"], "" if ms is None else " %s" % ms)
100
+ if cmd in ("mouse_down", "mouse_up"):
101
+ return "%s %s" % (cmd.replace("mouse_", ""), a["button"])
102
+ if cmd == "mouse_scroll":
103
+ return "scroll %s" % a["clicks"]
104
+ return "hscroll %s" % a["clicks"] # mouse_hscroll
105
+
106
+
107
+ TOOLS = [
108
+ ("key_tap", "Tap a key on the virtual keyboard.",
109
+ {"key": {"type": "string", "enum": sorted(elfkey.KEYS)},
110
+ "hold_ms": {"type": "integer", "minimum": 0, "maximum": elfkey.MAX_HOLD_MS}},
111
+ ["key"]),
112
+ ("key_down", "Hold a key down (release with key_up, or key_release for all).",
113
+ {"key": {"type": "string", "enum": sorted(elfkey.KEYS)}}, ["key"]),
114
+ ("key_up", "Release a held key.",
115
+ {"key": {"type": "string", "enum": sorted(elfkey.KEYS)}}, ["key"]),
116
+ ("key_release", "Release every held key at once โ€” the unstick button, for "
117
+ "when a caller died mid-hold or state is unknown.",
118
+ {}, []),
119
+ ("pad_press", "Tap a gamepad button or D-pad direction.",
120
+ {"button": {"type": "string",
121
+ "enum": sorted(set(elfpad.BUTTONS) | set(elfpad.DPAD) | set(elfpad.TRIGGERS))},
122
+ "hold_ms": {"type": "integer", "minimum": 0, "maximum": elfpad.MAX_HOLD_MS}},
123
+ ["button"]),
124
+ ("pad_down", "Hold a gamepad button or D-pad direction down.",
125
+ {"button": {"type": "string",
126
+ "enum": sorted(set(elfpad.BUTTONS) | set(elfpad.DPAD) | set(elfpad.TRIGGERS))}}, ["button"]),
127
+ ("pad_up", "Release a held gamepad button or D-pad direction.",
128
+ {"button": {"type": "string",
129
+ "enum": sorted(set(elfpad.BUTTONS) | set(elfpad.DPAD) | set(elfpad.TRIGGERS))}}, ["button"]),
130
+ ("pad_stick", "Move an analog stick (x/y in percent, -100..100). "
131
+ "Without hold_ms the stick stays; with it, it recenters after.",
132
+ {"side": {"type": "string", "enum": ["left", "right"]},
133
+ "x": {"type": "integer", "minimum": -100, "maximum": 100},
134
+ "y": {"type": "integer", "minimum": -100, "maximum": 100},
135
+ "hold_ms": {"type": "integer", "minimum": 0, "maximum": elfpad.MAX_HOLD_MS}},
136
+ ["side", "x", "y"]),
137
+ ("pad_trigger", "Set an analog trigger (percent 0..100). "
138
+ "Without hold_ms it stays; with it, it releases after.",
139
+ {"side": {"type": "string", "enum": ["left", "right"]},
140
+ "percent": {"type": "integer", "minimum": 0, "maximum": 100},
141
+ "hold_ms": {"type": "integer", "minimum": 0, "maximum": elfpad.MAX_HOLD_MS}},
142
+ ["side", "percent"]),
143
+ ("pad_center", "Return sticks, triggers and D-pad to neutral.", {}, []),
144
+ ("pad_reset", "Release every button and recenter everything.", {}, []),
145
+ ("mouse_move", "Move the pointer by (dx, dy) pixels. Optional glide_ms "
146
+ "spreads the move over time (smooth drags, human-ish motion).",
147
+ {"dx": {"type": "integer", "minimum": -elfmouse.MAX_MOVE, "maximum": elfmouse.MAX_MOVE},
148
+ "dy": {"type": "integer", "minimum": -elfmouse.MAX_MOVE, "maximum": elfmouse.MAX_MOVE},
149
+ "glide_ms": {"type": "integer", "minimum": 0, "maximum": elfmouse.MAX_HOLD_MS}},
150
+ ["dx", "dy"]),
151
+ ("mouse_click", "Click a mouse button.",
152
+ {"button": {"type": "string", "enum": ["left", "right", "middle"]},
153
+ "hold_ms": {"type": "integer", "minimum": 0, "maximum": elfmouse.MAX_HOLD_MS}},
154
+ ["button"]),
155
+ ("mouse_down", "Hold a mouse button down (start a drag).",
156
+ {"button": {"type": "string", "enum": ["left", "right", "middle"]}}, ["button"]),
157
+ ("mouse_up", "Release a mouse button (drop the drag).",
158
+ {"button": {"type": "string", "enum": ["left", "right", "middle"]}}, ["button"]),
159
+ ("mouse_scroll", "Scroll the wheel: positive clicks up, negative down.",
160
+ {"clicks": {"type": "integer", "minimum": -elfmouse.MAX_SCROLL,
161
+ "maximum": elfmouse.MAX_SCROLL}}, ["clicks"]),
162
+ ("mouse_hscroll", "Scroll horizontally: positive clicks right.",
163
+ {"clicks": {"type": "integer", "minimum": -elfmouse.MAX_SCROLL,
164
+ "maximum": elfmouse.MAX_SCROLL}}, ["clicks"]),
165
+ ("mouse_release", "Release every held mouse button at once โ€” the unstick "
166
+ "button, for when a caller died mid-drag.",
167
+ {}, []),
168
+ ]
169
+ TOOL_NAMES = {t[0] for t in TOOLS}
170
+ TOOL_SCHEMAS = {name: (props, required) for name, _d, props, required in TOOLS}
171
+
172
+
173
+ def _validate_args(name, args):
174
+ """Enforce the advertised schema at runtime: arguments must be an
175
+ object with exactly the known fields, correctly typed (a JSON true is
176
+ not an integer, whatever Python thinks), in range, and in the enum.
177
+ The daemon parser remains the final authority; this layer exists so a
178
+ bad call fails HERE, with a precise reason, before any FIFO write."""
179
+ if not isinstance(args, dict):
180
+ raise ValueError("arguments must be an object")
181
+ props, required = TOOL_SCHEMAS[name]
182
+ unknown = sorted(set(args) - set(props))
183
+ if unknown:
184
+ raise ValueError("unknown argument(s): %s" % ", ".join(unknown))
185
+ missing = [k for k in required if k not in args]
186
+ if missing:
187
+ raise ValueError("missing argument(s): %s" % ", ".join(missing))
188
+ for key, value in args.items():
189
+ schema = props[key]
190
+ if schema.get("type") == "integer":
191
+ if type(value) is not int: # bool is an int subclass; exclude it
192
+ raise ValueError("%s must be an integer" % key)
193
+ if "minimum" in schema and value < schema["minimum"]:
194
+ raise ValueError("%s must be >= %d" % (key, schema["minimum"]))
195
+ if "maximum" in schema and value > schema["maximum"]:
196
+ raise ValueError("%s must be <= %d" % (key, schema["maximum"]))
197
+ elif schema.get("type") == "string":
198
+ if not isinstance(value, str):
199
+ raise ValueError("%s must be a string" % key)
200
+ if "enum" in schema and value not in schema["enum"]:
201
+ raise ValueError("%s must be one of: %s"
202
+ % (key, ", ".join(map(str, schema["enum"]))))
203
+
204
+
205
+ def tools_list():
206
+ return {"tools": [
207
+ {"name": name, "description": desc,
208
+ "inputSchema": {"type": "object", "properties": props,
209
+ "required": required, "additionalProperties": False}}
210
+ for name, desc, props, required in TOOLS]}
211
+
212
+
213
+ def call_tool(name, args):
214
+ """Returns (text, is_error)."""
215
+ if name not in TOOL_NAMES:
216
+ return "unknown tool: %s" % name, True
217
+ try:
218
+ _validate_args(name, args)
219
+ except ValueError as e:
220
+ return "invalid arguments: %s" % e, True
221
+ try:
222
+ if name.startswith("key_"):
223
+ return _send(KEY_FIFO, _key_line(name, args), elfkey.parse_command), False
224
+ if name.startswith("mouse_"):
225
+ return _send(MOUSE_FIFO, _mouse_line(name, args),
226
+ elfmouse.parse_command), False
227
+ return _send(PAD_FIFO, _pad_line(name, args), elfpad.parse_command), False
228
+ except ValueError as e:
229
+ return "refused: %s" % e, True
230
+ except KeyError as e:
231
+ return "missing argument: %s" % e, True
232
+ except OSError as e:
233
+ if e.errno in (errno.ENXIO, errno.ENOENT):
234
+ return "daemon not running (no reader on the FIFO)", True
235
+ if e.errno == errno.EAGAIN:
236
+ return "daemon busy (FIFO full) โ€” retry shortly", True
237
+ if e.errno in (errno.EACCES, errno.EPERM):
238
+ return ("permission denied on the FIFO โ€” the daemons create it "
239
+ "owner-only (0600); run this server as the same user"), True
240
+ if e.errno == errno.EPIPE:
241
+ return "daemon disconnected (broken pipe)", True
242
+ return "FIFO I/O error: %s" % (e.strerror or e), True
243
+
244
+
245
+ def handle(msg):
246
+ """One JSON-RPC message -> response dict, or None for notifications."""
247
+ if not isinstance(msg, dict):
248
+ return {"jsonrpc": "2.0", "id": None, "error": {
249
+ "code": -32600, "message": "invalid request: not a JSON object"}}
250
+ method = msg.get("method")
251
+ msg_id = msg.get("id")
252
+ notification = "id" not in msg # JSON-RPC: notifications get NO response,
253
+ if notification and method != "tools/call": # not even errors or results
254
+ return None
255
+ if method == "initialize":
256
+ return {"jsonrpc": "2.0", "id": msg_id, "result": {
257
+ "protocolVersion": PROTOCOL_VERSION,
258
+ "capabilities": {"tools": {}},
259
+ "serverInfo": {"name": "elfdeckai", "version": "1.0.4"}}}
260
+ if method == "ping":
261
+ return {"jsonrpc": "2.0", "id": msg_id, "result": {}}
262
+ if method == "tools/list":
263
+ return {"jsonrpc": "2.0", "id": msg_id, "result": tools_list()}
264
+ if method == "tools/call":
265
+ params = msg.get("params") or {}
266
+ if not isinstance(params, dict) or not isinstance(params.get("name"), str) \
267
+ or ("arguments" in params and not isinstance(params["arguments"], dict)):
268
+ return {"jsonrpc": "2.0", "id": msg_id, "error": {
269
+ "code": -32602, "message": "invalid params: tools/call needs a "
270
+ "string name and an object arguments"}}
271
+ text, is_error = call_tool(params.get("name"), params.get("arguments") or {})
272
+ if notification:
273
+ return None # the call executed; a notification is never answered
274
+ return {"jsonrpc": "2.0", "id": msg_id, "result": {
275
+ "content": [{"type": "text", "text": text}], "isError": is_error}}
276
+ if msg_id is None:
277
+ return None # notification (e.g. notifications/initialized)
278
+ return {"jsonrpc": "2.0", "id": msg_id,
279
+ "error": {"code": -32601, "message": "method not found: %s" % method}}
280
+
281
+
282
+ def _reject_constant(value):
283
+ raise ValueError("invalid JSON constant: %s" % value)
284
+
285
+
286
+ MAX_REQUEST_BYTES = 1024 * 1024 # one request line; plenty for a tool call
287
+
288
+
289
+ def main():
290
+ stream = sys.stdin.buffer
291
+ while True:
292
+ raw = stream.readline(MAX_REQUEST_BYTES + 1)
293
+ if not raw:
294
+ break
295
+ if len(raw) > MAX_REQUEST_BYTES:
296
+ while raw and not raw.endswith(b"\n"):
297
+ raw = stream.readline(MAX_REQUEST_BYTES + 1)
298
+ _emit({"jsonrpc": "2.0", "id": None, "error": {
299
+ "code": -32600, "message": "request too large"}})
300
+ continue
301
+ try:
302
+ line = raw.decode("utf-8").strip()
303
+ except UnicodeDecodeError:
304
+ _emit({"jsonrpc": "2.0", "id": None, "error": {
305
+ "code": -32700, "message": "parse error"}})
306
+ continue
307
+ if not line:
308
+ continue
309
+ try:
310
+ msg = json.loads(line, parse_constant=_reject_constant)
311
+ except ValueError:
312
+ _emit({"jsonrpc": "2.0", "id": None, "error": {
313
+ "code": -32700, "message": "parse error"}})
314
+ continue
315
+ try:
316
+ response = handle(msg)
317
+ except Exception:
318
+ response = {"jsonrpc": "2.0",
319
+ "id": msg.get("id") if isinstance(msg, dict) else None,
320
+ "error": {"code": -32603, "message": "internal error"}}
321
+ if response is not None:
322
+ _emit(response)
323
+
324
+
325
+ def _emit(response):
326
+ sys.stdout.write(json.dumps(response) + "\n")
327
+ sys.stdout.flush()
328
+
329
+
330
+ if __name__ == "__main__":
331
+ main()