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.
- elfdeckai-1.0.4.dist-info/METADATA +230 -0
- elfdeckai-1.0.4.dist-info/RECORD +10 -0
- elfdeckai-1.0.4.dist-info/WHEEL +5 -0
- elfdeckai-1.0.4.dist-info/entry_points.txt +5 -0
- elfdeckai-1.0.4.dist-info/licenses/LICENSE +21 -0
- elfdeckai-1.0.4.dist-info/top_level.txt +4 -0
- elfdeckai_mcp.py +331 -0
- elfkey.py +260 -0
- elfmouse.py +302 -0
- elfpad.py +379 -0
|
@@ -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)
|
|
24
|
+

|
|
25
|
+

|
|
26
|
+

|
|
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,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.
|
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()
|