joeylib2/scripts/lib/qmp.py

155 lines
5.8 KiB
Python

#!/usr/bin/env python3
# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
# See LICENSE.md for license information.
# SPDX-License-Identifier: MIT
"""qmp.py - drive a QEMU machine through its QMP socket.
The Be API ports' gates (scripts/lib/beGate.sh) take the screen and press keys
this way, as a person at the machine would: the keys arrive through the
emulated PS/2 keyboard.
qmp.py SOCKET screendump FILE.png the screen, as QEMU's display has it
qmp.py SOCKET type TEXT type TEXT (US layout, shift as needed)
qmp.py SOCKET keys KEY... press the KEYs, one after another
qmp.py SOCKET hold KEY MS hold one key down for MS milliseconds
qmp.py SOCKET down KEY press one key, and leave it down
qmp.py SOCKET up KEY let go of it
qmp.py SOCKET move DX DY move the mouse DX, DY (relative, as a PS/2
mouse moves, in steps a PS/2 packet holds;
the system stops it at the screen's edge)
qmp.py SOCKET quit end QEMU
KEY is a QEMU key name (a qcode: esc, ret, spc, a, 1, ...).
"""
import json
import socket
import sys
import time
# How long each typed key is held, and the pause after it: slow enough that
# a program reading its input once a frame sees every key.
TYPE_HOLD_MS = 100
TYPE_GAP_SECONDS = 0.15
MS_PER_SECOND = 1000.0
# A mouse move goes in steps of at most this many counts on each axis, with
# this pause after each: BeOS R5's mouse driver gets a single long move wrong.
MOVE_STEP_MAX = 100
MOVE_GAP_SECONDS = 0.02
# Characters to (qcode, shifted) on a US keyboard.
SHIFTED_DIGITS = ')!@#$%^&*('
PUNCTUATION = {
' ': ('spc', False), '.': ('dot', False), ',': ('comma', False),
'/': ('slash', False), ';': ('semicolon', False), "'": ('apostrophe', False),
'-': ('minus', False), '=': ('equal', False), '[': ('bracket_left', False),
']': ('bracket_right', False), '\\': ('backslash', False), '`': ('grave_accent', False),
':': ('semicolon', True), '?': ('slash', True), '>': ('dot', True), '<': ('comma', True),
'"': ('apostrophe', True), '_': ('minus', True), '+': ('equal', True),
'{': ('bracket_left', True), '}': ('bracket_right', True), '|': ('backslash', True),
'~': ('grave_accent', True),
}
class Qmp:
"""One QMP connection: the greeting read and capabilities negotiated."""
def __init__(self, path):
self.sock = socket.socket(socket.AF_UNIX)
self.sock.connect(path)
self.file = self.sock.makefile('rw')
self.file.readline()
self.execute('qmp_capabilities')
def execute(self, command, arguments=None):
"""Run one command; its return value. Events in between are skipped."""
request = {'execute': command}
if arguments is not None:
request['arguments'] = arguments
self.file.write(json.dumps(request) + '\n')
self.file.flush()
while True:
reply = json.loads(self.file.readline())
if 'error' in reply:
raise RuntimeError('%s: %s' % (command, reply['error'].get('desc', reply['error'])))
if 'return' in reply:
return reply['return']
def keys(self, qcodes, holdMs):
"""Press qcodes together, hold them holdMs, let them go."""
self.execute('send-key', {
'keys': [{'type': 'qcode', 'data': code} for code in qcodes],
'hold-time': holdMs,
})
def charKey(ch):
"""(qcode, shifted) for one character."""
if 'a' <= ch <= 'z' or '0' <= ch <= '9':
return ch, False
if 'A' <= ch <= 'Z':
return ch.lower(), True
if ch in SHIFTED_DIGITS:
return str(SHIFTED_DIGITS.index(ch)), True
if ch in PUNCTUATION:
return PUNCTUATION[ch]
raise ValueError('no key types %r' % ch)
def key(qmp, code, down):
"""Press or let go of one key."""
qmp.execute('input-send-event', {'events': [
{'type': 'key', 'data': {'down': down, 'key': {'type': 'qcode', 'data': code}}},
]})
def move(qmp, dx, dy):
"""Move the mouse dx, dy in MOVE_STEP_MAX steps."""
steps = max(1, -(-max(abs(dx), abs(dy)) // MOVE_STEP_MAX))
for step in range(steps):
stepX = dx * (step + 1) // steps - dx * step // steps
stepY = dy * (step + 1) // steps - dy * step // steps
qmp.execute('input-send-event', {'events': [
{'type': 'rel', 'data': {'axis': 'x', 'value': stepX}},
{'type': 'rel', 'data': {'axis': 'y', 'value': stepY}},
]})
time.sleep(MOVE_GAP_SECONDS)
def tap(qmp, qcodes):
"""Press qcodes together for a typed key's time, then pause after them."""
qmp.keys(qcodes, TYPE_HOLD_MS)
time.sleep(TYPE_HOLD_MS / MS_PER_SECOND + TYPE_GAP_SECONDS)
def main(argv):
if len(argv) < 3:
sys.exit(__doc__)
qmp = Qmp(argv[1])
command = argv[2]
if command == 'screendump' and len(argv) == 4:
qmp.execute('screendump', {'filename': argv[3], 'format': 'png'})
elif command == 'type' and len(argv) == 4:
for ch in argv[3]:
code, shifted = charKey(ch)
tap(qmp, ['shift', code] if shifted else [code])
elif command == 'keys' and len(argv) >= 4:
for code in argv[3:]:
tap(qmp, [code])
elif command == 'hold' and len(argv) == 5:
holdMs = int(argv[4])
qmp.keys([argv[3]], holdMs)
time.sleep(holdMs / MS_PER_SECOND)
elif command in ('down', 'up') and len(argv) == 4:
key(qmp, argv[3], command == 'down')
elif command == 'move' and len(argv) == 5:
move(qmp, int(argv[3]), int(argv[4]))
elif command == 'quit' and len(argv) == 3:
qmp.execute('quit')
else:
sys.exit(__doc__)
if __name__ == '__main__':
main(sys.argv)