HID Keyboard & Mouse¶
The HID resource provides keyboard and mouse control over the PiKVM's HID interface.
Get state¶
state = await kvm.hid.get_state()
print(f"Online: {state.online}")
print(f"Busy: {state.busy}")
print(f"Keyboard online: {state.keyboard.online}")
print(f"Caps Lock: {state.keyboard.leds.caps}")
print(f"Mouse online: {state.mouse.online}")
print(f"Mouse absolute: {state.mouse.absolute}")
print(f"Mouse outputs: {state.mouse.outputs.available}")
print(f"Jiggler: {state.jiggler.enabled}")
state.connected reports whether the target host has the HID plugged in.
Only the MCU-based backends can tell — otg, ch9329 and bt report
None. See Connection control for what that does and
does not imply.
Idle time¶
Seconds since the last key press or mouse movement kvmd delivered, from any of its clients — the web UI, another script, or this one. Input typed on a keyboard plugged straight into the host does not reset it:
Type text¶
Send a string as keyboard input:
await kvm.hid.type_text("Hello from aiopikvm!")
# Type with a specific layout instead of the device-wide default
await kvm.hid.type_text("Hello", keymap="en-us")
# Slow down for firmware that drops fast input (0 to 5 seconds per key)
await kvm.hid.type_text("Hello", delay=0.05)
kvmd answers only once the whole string is typed, so a large delay or a
long text needs a wider timeout than the 10-second client default:
limit is server-side truncation, not chunking: kvmd types the first
limit characters and discards the rest. The default is 0 (type
everything).
# Type at most 50 characters and drop the remainder
await kvm.hid.type_text("Long text...", limit=50)
Send key events¶
# Press and release a key
await kvm.hid.send_key("KeyA")
# Press only (hold)
await kvm.hid.send_key("KeyA", state=True)
# Release only
await kvm.hid.send_key("KeyA", state=False)
# Press, and have kvmd release it in the same event
await kvm.hid.send_key("KeyA", state=True, finish=True)
finish, and the keys it does not release¶
A held key is held until a release arrives, and a script that dies between
the two never sends one — the device keeps typing aaaaaaa at whatever
was on screen. finish=True asks kvmd to send the release itself, straight
after the press and before it reads anything else, which is the one
keystroke a lost connection cannot interrupt halfway.
It is a press flag: kvmd reads it beside state and acts on it only when
that is a press, so send_key("KeyA", state=False, finish=True) sends the
plain release and nothing else goes on the wire.
kvmd exempts the modifiers, because holding those is what they are for —
ShiftLeft, ShiftRight, ControlLeft, ControlRight, AltLeft,
AltRight, MetaLeft, MetaRight, and PrintScreen, which it counts as
one for the sake of Alt+SysRq. Asking for finish on one of those nine
presses the key and leaves it held, with nothing said either way.
Note
send_key("KeyA") with no state is the press-and-release above: one
press carrying finish, not two events. The exempt keys are exempt there
too, so send_key("ShiftLeft") presses Shift and leaves it down. Pass
state=False to let it up.
The WebSocket takes the same flag, with one wrinkle of its own on the binary channel.
Key names¶
The names are kvmd's, and they are written the way a browser reports them in
KeyboardEvent.code — "KeyA", not "a"; "Digit1", not "1". Matching
is case-sensitive, so "keya" is refused like any other name kvmd does not
know. The two sets are not the same, though: kvmd knows 126 names and the
DOM defines around 200, so forwarding a browser's code straight through
will eventually hand it something it has no entry for — NumpadEqual and
BrowserBack are real code values with no key behind them here.
KEY_NAMES holds every one kvmd does know:
from aiopikvm import KEY_NAMES
if key not in KEY_NAMES:
raise ValueError(f"kvmd has no key named {key!r}")
await kvm.hid.send_key(key)
Over HTTP the check is a convenience — send_key() raises APIError with
HTTP 400, and the message names the offending key unless it is longer than
16 characters, which kvmd's validator refuses on length alone. Over the
WebSocket it is the only signal there is: kvmd drops the
frame inside its handler and answers nothing, so a typo and a keystroke that
landed look exactly alike.
kvmd exposes the table through no endpoint, so KEY_NAMES is a copy: it was
read off a device running kvmd 4.206 and is checked against that capture by
the test suite. A device on another version may know names it does not list,
which is why nothing in the client enforces it — a name outside the set is
sent as given.
Keyboard shortcuts¶
# Ctrl+A
await kvm.hid.send_shortcut("ControlLeft", "KeyA")
# Ctrl+Alt+Delete
await kvm.hid.send_shortcut("ControlLeft", "AltLeft", "Delete")
The server presses the keys in order and releases them in reverse order, with a fixed 50 ms delay between events.
Mouse control¶
Move mouse¶
# Move to absolute coordinates
await kvm.hid.send_mouse_move(500, 300)
# Relative movement
await kvm.hid.send_mouse_relative(10, -5)
Mouse buttons¶
# Click (press and release)
await kvm.hid.send_mouse_button("left")
# Press only
await kvm.hid.send_mouse_button("left", state=True)
# Release only
await kvm.hid.send_mouse_button("left", state=False)
# Right click
await kvm.hid.send_mouse_button("right")
The names are the MouseButton type
(the values). Two of
them read oddly: up and down are the side buttons a browser reports as
back and forward, not wheel directions — the wheel is below.
Mouse wheel¶
# Scroll down
await kvm.hid.send_mouse_wheel(0, -5)
# Scroll up
await kvm.hid.send_mouse_wheel(0, 5)
# Horizontal step
await kvm.hid.send_mouse_wheel(3, 0)
Steps are in kvmd's own -127 to 127 range, clamped rather than rejected,
and are not a browser's pixel deltas. A browser reports a scroll-down gesture
as a positive deltaY; kvmd's web UI negates it and sizes it by its
scroll-rate setting (1 to 25, 5 by default), so the gesture reaches the
device as delta_y = -5 — which is what settles the direction in the sample
above. ch9329 keeps only the sign and sends one detent, a zero counting as
negative, so the size is lost there. Several steps can go in one frame over
the WebSocket.
delta_x needs a backend with a horizontal wheel behind it. In kvmd 4.206
only otg has one, and only while its horizontal_wheel option is on, which
is the default; serial and spi leave the slot empty and the Arduino and
Pico firmware behind them refuse it in any case, while ch9329 and bt
receive it and throw it away. None of them says a word about it.
Which way a positive delta_x pans is not settled here. What settles the
vertical axis — kvmd's negation agreeing with the usual reading of the HID
field — comes apart on the horizontal one, where the two point opposite ways,
and no target screen was on hand to break the tie. kvmd passes the byte
through untouched, so whichever way 3 goes, -3 goes the other.
HID parameters¶
# Set keyboard output type
await kvm.hid.set_params(keyboard_output="usb")
# Set mouse output type
await kvm.hid.set_params(mouse_output="usb_rel")
# Toggle the mouse jiggler, which keeps the host from going idle
await kvm.hid.set_params(jiggler=True)
The jiggler has two flags and they mean different things
jiggler=True writes state.jiggler.active — whether it is nudging the
pointer now. state.jiggler.enabled says the device was configured with
a jiggler at all, and nothing in the API moves it. Reading it back after
a write shows no change, which looks exactly like a write that was
ignored:
await kvm.hid.set_params(jiggler=True)
state = await kvm.hid.get_state()
print(state.jiggler.enabled) # True — and it was True before
print(state.jiggler.active) # True — this is the one that moved
interval is the idle time in seconds before it starts, and is
read-only over the API too.
The output names are typed as KeyboardOutput and MouseOutput
(the values), so a typo
is a type error rather than an HTTP 400. That is kvmd's validator, though,
and passing it is not the same as taking effect. kvmd checks the name against
the fixed list whatever backend is running, and then hands it to a backend
that may have no use for it: only the MCU backends act on keyboard_output
at all, while otg, ch9329 and bt discard it and answer 200.
What the running backend offers is in the state:
state.keyboard.outputs.available and state.mouse.outputs.available.
Either can be empty — an OTG keyboard offers no choice at all, while its
mouse still moves between usb and usb_rel.
A name kvmd knows but the backend does not advertise is still not an error,
and what becomes of it differs: otg ignores it under a 200, while ch9329
advertises two names and acts on all five, taking everything but usb as its
relative mouse. Read the state back rather than assume the name was applied
as asked — state.mouse.outputs.active names the mouse in use and
state.mouse.absolute says whether it reports positions or movement.
Connection control¶
set_connected() unplugs the emulated keyboard and mouse from the target
host, and plugs them back in. Only the MCU-based backends do it — the
ones driving a separate microcontroller, hid.type set to serial or
spi in the kvmd config. Under otg, ch9329 or bt the call lands on a
base implementation that discards its argument, so kvmd answers 200 and
nothing happens. (The device these docs were verified against, a v3, runs
otg.)
Nothing in the response says which of the two happened, so read the state — in the one direction it is good for:
state = await kvm.hid.get_state()
if state.connected is not None:
# This backend implements it: the host stops seeing the keyboard.
await kvm.hid.set_connected(False)
await asyncio.sleep(2)
await kvm.hid.set_connected(True)
connected being None is not proof of the opposite. An MCU backend
reports None too until its microcontroller has answered with a status word
carrying the flag, so a board that is merely offline, or whose firmware
answers the shorter pong, looks exactly like one that cannot unplug
anything. state.online rules out the offline board; the firmware that
never sends the flag cannot be told apart at all.
The change travels to the microcontroller through a queue and the call returns as soon as it is queued. It also empties that queue on the way in, so keystrokes sent a moment earlier and not yet delivered are dropped with it — and so is the disconnect itself, if a reconnect follows before the queue has been read. That is what the sleep above is for: back to back, the two calls are a disconnect the host never notices, or never receives at all.
reset() is a different matter. Every backend overrides it, but what it
does differs:
hid.type |
What reset() does |
|---|---|
otg |
Drops the queued input and releases every held key and button |
bt |
The same, then drops the Bluetooth clients — unpaired, unless unpair_on_close is off, so the host has to pair again |
serial, spi |
Resets the microcontroller; queued input survives |
ch9329 |
Nothing observable: the reset request is commented out in kvmd 4.206, leaving an internal busy flag get_state() never reports |
Under otg that makes it the way out of a modifier left stuck by a script
that died mid-shortcut.
Keymaps¶
keymaps = await kvm.hid.get_keymaps()
print(f"Default: {keymaps.default}")
print(f"Available: {', '.join(keymaps.available)}")
The names are what type_text(keymap=...) accepts. The device-wide default
is set in the kvmd config and is not necessarily en-us.
Full example¶
import asyncio
from aiopikvm import PiKVM
async def main():
async with PiKVM("https://pikvm.local", user="admin", passwd="admin") as kvm:
# Type credentials into a login form
await kvm.hid.type_text("admin")
await kvm.hid.send_key("Tab")
await kvm.hid.type_text("password123")
await kvm.hid.send_key("Enter")
asyncio.run(main())