Add vm_mouse_move: chunked relative PS/2 motion with corner homing and clicks
This commit is contained in:
parent
7020f96fa2
commit
1dbe453731
@ -29,7 +29,7 @@ claude mcp add mcqemu -- uv run --directory /path/to/mcqemu mcqemu
|
||||
| Sandboxes | `sandbox_vm` (overlay + launch + wait-for-agent in one call), `sandbox_destroy` |
|
||||
| Inspect | `list_vms`, `vm_info` |
|
||||
| Live snapshots | `vm_snapshot_create` / `restore` / `delete` / `list` |
|
||||
| See & drive | `vm_screenshot` (PNG), `vm_send_keys`, `vm_type_text`, `vm_click`, `vm_serial_read` |
|
||||
| See & drive | `vm_screenshot` (PNG), `vm_send_keys`, `vm_type_text`, `vm_click`, `vm_mouse_move` (relative PS/2, for guests without tablet drivers), `vm_serial_read` |
|
||||
| Disk images | `image_create`, `image_info`, `image_convert`, `image_resize`, `image_snapshot_*` |
|
||||
| Guest agent | `guest_ping`, `guest_info`, `guest_exec`, `guest_file_read`, `guest_file_write` |
|
||||
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
"""See & drive: screenshots, keyboard/mouse injection, serial console."""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
@ -15,6 +17,9 @@ from ._common import require_vm
|
||||
|
||||
_ABS_MAX = 32767 # QEMU absolute-pointer coordinate space
|
||||
|
||||
# PS/2-style relative buttons for HMP mouse_button (bitmask).
|
||||
_MOUSE_BUTTONS = {"left": 1, "right": 2, "middle": 4}
|
||||
|
||||
|
||||
def _png_dimensions(data: bytes) -> tuple[int, int]:
|
||||
if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
|
||||
@ -145,6 +150,87 @@ async def vm_click(
|
||||
return {"name": name, "clicked": f"{button} at ({x}, {y})", "double": double}
|
||||
|
||||
|
||||
def _steps(delta: int, step: int) -> list[int]:
|
||||
"""Split a delta into chunks no larger than `step` (preserving sign)."""
|
||||
if delta == 0:
|
||||
return []
|
||||
sign = 1 if delta > 0 else -1
|
||||
full, rest = divmod(abs(delta), step)
|
||||
return [sign * step] * full + ([sign * rest] if rest else [])
|
||||
|
||||
|
||||
async def vm_mouse_move(
|
||||
name: str,
|
||||
dx: int = 0,
|
||||
dy: int = 0,
|
||||
home: Literal["top-left", "top-right", "bottom-left", "bottom-right"] | None = None,
|
||||
click: Literal["left", "right", "middle"] | None = None,
|
||||
double: bool = False,
|
||||
step: int = 32,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Relative mouse control for guests WITHOUT absolute-pointer (tablet)
|
||||
drivers — most pre-2010 OSes. Use this when vm_click has no visible
|
||||
effect. Motion goes to the emulated PS/2 mouse in small steps (<= `step`
|
||||
px per packet) because guests often desync or apply acceleration on large
|
||||
deltas.
|
||||
|
||||
Recommended pattern: pass home="bottom-right" (or another corner) to pin
|
||||
the cursor to a known position first, then dx/dy toward the target, then
|
||||
vm_screenshot to verify where the cursor actually landed (guest
|
||||
acceleration may scale motion), correct with further small moves, and
|
||||
finally click. `click` presses that button after moving; double=True
|
||||
double-clicks."""
|
||||
if click is not None and click not in _MOUSE_BUTTONS:
|
||||
raise ToolError(f"Unsupported button {click!r}: use left, right, or middle.")
|
||||
if step < 1 or step > 120:
|
||||
raise ToolError("step must be between 1 and 120 (PS/2 deltas are small signed bytes).")
|
||||
|
||||
_, record = require_vm(ctx, name)
|
||||
|
||||
homed_steps = 0
|
||||
if home is not None:
|
||||
# Screen size bounds how far the corner can be; overshoot a little so
|
||||
# the cursor pins against the edge regardless of starting position.
|
||||
png = await _screendump(ctx, name)
|
||||
width, height = _png_dimensions(png)
|
||||
sx = -1 if "left" in home else 1
|
||||
sy = -1 if "top" in home else 1
|
||||
homed_steps = math.ceil(max(width, height) / step) + 2
|
||||
|
||||
async with qmp_session(record) as client:
|
||||
|
||||
async def hmp(cmd: str):
|
||||
return await execute(client, "human-monitor-command", {"command-line": cmd})
|
||||
|
||||
for _ in range(homed_steps):
|
||||
await hmp(f"mouse_move {sx * step} {sy * step}")
|
||||
await asyncio.sleep(0.02)
|
||||
for chunk_x in _steps(dx, step):
|
||||
await hmp(f"mouse_move {chunk_x} 0")
|
||||
await asyncio.sleep(0.02)
|
||||
for chunk_y in _steps(dy, step):
|
||||
await hmp(f"mouse_move 0 {chunk_y}")
|
||||
await asyncio.sleep(0.02)
|
||||
if click is not None:
|
||||
for _ in range(2 if double else 1):
|
||||
await hmp(f"mouse_button {_MOUSE_BUTTONS[click]}")
|
||||
await asyncio.sleep(0.08)
|
||||
await hmp("mouse_button 0")
|
||||
await asyncio.sleep(0.12)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"homed": home,
|
||||
"moved": [dx, dy],
|
||||
"clicked": f"{click}{' double' if double and click else ''}" if click else None,
|
||||
"note": (
|
||||
"Motion is relative and the guest may scale it (acceleration). "
|
||||
"Take a vm_screenshot to verify the cursor position before clicking."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def vm_serial_read(name: str, tail_lines: int = 50, ctx: Context = None) -> dict:
|
||||
"""Read the last lines of the VM's serial console log. Only useful when
|
||||
the guest writes to its serial port (kernel console=ttyS0, or text-mode
|
||||
@ -167,5 +253,5 @@ async def vm_serial_read(name: str, tail_lines: int = 50, ctx: Context = None) -
|
||||
|
||||
|
||||
def register(mcp: FastMCP) -> None:
|
||||
for fn in (vm_screenshot, vm_send_keys, vm_type_text, vm_click, vm_serial_read):
|
||||
for fn in (vm_screenshot, vm_send_keys, vm_type_text, vm_click, vm_mouse_move, vm_serial_read):
|
||||
mcp.tool(fn)
|
||||
|
||||
@ -119,3 +119,80 @@ async def test_serial_read_tail(dirs, fake_qmp):
|
||||
)
|
||||
assert data["total_lines"] == 100
|
||||
assert data["tail"] == "line97\nline98\nline99"
|
||||
|
||||
|
||||
def hmp_calls():
|
||||
return [a["command-line"] for c, a in FakeQMPClient.calls if c == "human-monitor-command"]
|
||||
|
||||
|
||||
async def test_mouse_move_chunks_deltas(dirs, fake_qmp):
|
||||
FakeQMPClient.responses["human-monitor-command"] = ""
|
||||
write_registry(dirs, seeded_record(dirs, "vm1"))
|
||||
async with Client(mcp) as client:
|
||||
data = result_data(
|
||||
await client.call_tool(
|
||||
"vm_mouse_move", {"name": "vm1", "dx": 70, "dy": -75, "step": 32}
|
||||
)
|
||||
)
|
||||
assert data["moved"] == [70, -75]
|
||||
assert hmp_calls() == [
|
||||
"mouse_move 32 0",
|
||||
"mouse_move 32 0",
|
||||
"mouse_move 6 0",
|
||||
"mouse_move 0 -32",
|
||||
"mouse_move 0 -32",
|
||||
"mouse_move 0 -11",
|
||||
]
|
||||
|
||||
|
||||
async def test_mouse_move_home_pins_to_corner(dirs, fake_qmp):
|
||||
arm_screendump(width=320, height=200)
|
||||
FakeQMPClient.responses["human-monitor-command"] = ""
|
||||
write_registry(dirs, seeded_record(dirs, "vm1"))
|
||||
async with Client(mcp) as client:
|
||||
data = result_data(
|
||||
await client.call_tool(
|
||||
"vm_mouse_move", {"name": "vm1", "home": "bottom-right", "step": 100}
|
||||
)
|
||||
)
|
||||
assert data["homed"] == "bottom-right"
|
||||
moves = hmp_calls()
|
||||
# ceil(320/100) + 2 = 6 homing steps, all toward +x/+y
|
||||
assert moves == ["mouse_move 100 100"] * 6
|
||||
|
||||
|
||||
async def test_mouse_move_home_top_left_direction(dirs, fake_qmp):
|
||||
arm_screendump(width=100, height=100)
|
||||
FakeQMPClient.responses["human-monitor-command"] = ""
|
||||
write_registry(dirs, seeded_record(dirs, "vm1"))
|
||||
async with Client(mcp) as client:
|
||||
await client.call_tool("vm_mouse_move", {"name": "vm1", "home": "top-left", "step": 50})
|
||||
assert set(hmp_calls()) == {"mouse_move -50 -50"}
|
||||
|
||||
|
||||
async def test_mouse_move_click_sequence(dirs, fake_qmp):
|
||||
FakeQMPClient.responses["human-monitor-command"] = ""
|
||||
write_registry(dirs, seeded_record(dirs, "vm1"))
|
||||
async with Client(mcp) as client:
|
||||
data = result_data(
|
||||
await client.call_tool(
|
||||
"vm_mouse_move", {"name": "vm1", "click": "left", "double": True}
|
||||
)
|
||||
)
|
||||
assert data["clicked"] == "left double"
|
||||
assert hmp_calls() == ["mouse_button 1", "mouse_button 0"] * 2
|
||||
|
||||
|
||||
async def test_mouse_move_right_click(dirs, fake_qmp):
|
||||
FakeQMPClient.responses["human-monitor-command"] = ""
|
||||
write_registry(dirs, seeded_record(dirs, "vm1"))
|
||||
async with Client(mcp) as client:
|
||||
await client.call_tool("vm_mouse_move", {"name": "vm1", "click": "right"})
|
||||
assert hmp_calls() == ["mouse_button 2", "mouse_button 0"]
|
||||
|
||||
|
||||
async def test_mouse_move_rejects_bad_step(dirs, fake_qmp):
|
||||
write_registry(dirs, seeded_record(dirs, "vm1"))
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(ToolError, match="step"):
|
||||
await client.call_tool("vm_mouse_move", {"name": "vm1", "dx": 10, "step": 500})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user