pikvm/pikvm#1485, pikvm/pikvm#187: kvmd-localhid to pass USB keyboard and mouse through PiKVM to the host

This commit is contained in:
Maxim Devaev 2025-05-23 23:44:59 +03:00
parent 625b2aa970
commit 310b23edad
11 changed files with 636 additions and 2 deletions

View File

@ -0,0 +1,16 @@
[Unit]
Description=PiKVM - Local HID to KVMD proxy
After=kvmd.service systemd-udevd.service
[Service]
User=kvmd-localhid
Group=kvmd-localhid
Type=simple
Restart=always
RestartSec=3
ExecStart=/usr/bin/kvmd-localhid --run
TimeoutStopSec=3
[Install]
WantedBy=multi-user.target

View File

@ -4,6 +4,7 @@ g kvmd-media - -
g kvmd-pst - -
g kvmd-ipmi - -
g kvmd-vnc - -
g kvmd-localhid - -
g kvmd-nginx - -
g kvmd-janus - -
g kvmd-certbot - -
@ -13,6 +14,7 @@ u kvmd-media - "PiKVM - The media proxy"
u kvmd-pst - "PiKVM - Persistent storage" -
u kvmd-ipmi - "PiKVM - IPMI to KVMD proxy" -
u kvmd-vnc - "PiKVM - VNC to KVMD/Streamer proxy" -
u kvmd-localhid - "PiKVM - Local HID to KVMD proxy" -
u kvmd-nginx - "PiKVM - HTTP entrypoint" -
u kvmd-janus - "PiKVM - Janus WebRTC Gateway" -
u kvmd-certbot - "PiKVM - Certbot-Renew for KVMD-Nginx"
@ -36,6 +38,10 @@ m kvmd-vnc kvmd
m kvmd-vnc kvmd-selfauth
m kvmd-vnc kvmd-certbot
m kvmd-localhid input
m kvmd-localhid kvmd
m kvmd-localhid kvmd-selfauth
m kvmd-janus kvmd
m kvmd-janus audio

View File

@ -211,6 +211,18 @@ async def wait_first(*aws: asyncio.Task) -> tuple[set[asyncio.Task], set[asyncio
return (await asyncio.wait(list(aws), return_when=asyncio.FIRST_COMPLETED))
# =====
async def spawn_and_follow(*coros: Coroutine) -> None:
tasks: list[asyncio.Task] = list(map(asyncio.create_task, coros))
try:
await asyncio.gather(*tasks)
except Exception:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
raise
# =====
async def close_writer(writer: asyncio.StreamWriter) -> bool:
closing = writer.is_closing()

View File

@ -804,6 +804,13 @@ def _get_config_scheme() -> dict:
},
},
"localhid": {
"kvmd": {
"unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"),
"timeout": Option(5.0, type=valid_float_f01),
},
},
"nginx": {
"http": {
"ipv4": Option("0.0.0.0", type=functools.partial(valid_ip, v6=False)),

View File

@ -0,0 +1,45 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2020 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from ...clients.kvmd import KvmdClient
from ... import htclient
from .. import init
from .server import LocalHidServer
# =====
def main(argv: (list[str] | None)=None) -> None:
config = init(
prog="kvmd-localhid",
description=" Local HID to KVMD proxy",
check_run=True,
argv=argv,
)[2].localhid
user_agent = htclient.make_user_agent("KVMD-LocalHID")
LocalHidServer(
kvmd=KvmdClient(user_agent=user_agent, **config.kvmd._unpack()),
).run()

View File

@ -0,0 +1,24 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
from . import main
main()

152
kvmd/apps/localhid/hid.py Normal file
View File

@ -0,0 +1,152 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import asyncio
from typing import Final
from typing import Generator
import evdev
from evdev import ecodes
# =====
class Hid: # pylint: disable=too-many-instance-attributes
KEY: Final[int] = 0
MOUSE_BUTTON: Final[int] = 1
MOUSE_REL: Final[int] = 2
MOUSE_WHEEL: Final[int] = 3
def __init__(self, path: str) -> None:
self.__device = evdev.InputDevice(path)
caps = self.__device.capabilities(absinfo=False)
syns = caps.get(ecodes.EV_SYN, [])
self.__has_syn = (ecodes.SYN_REPORT in syns)
leds = caps.get(ecodes.EV_LED, [])
self.__has_caps = (ecodes.LED_CAPSL in leds)
self.__has_scroll = (ecodes.LED_SCROLLL in leds)
self.__has_num = (ecodes.LED_NUML in leds)
keys = caps.get(ecodes.EV_KEY, [])
self.__has_keyboard = (
ecodes.KEY_LEFTCTRL in keys
or ecodes.KEY_RIGHTCTRL in keys
or ecodes.KEY_LEFTSHIFT in keys
or ecodes.KEY_RIGHTSHIFT in keys
)
rels = caps.get(ecodes.EV_REL, [])
self.__has_mouse_rel = (
ecodes.BTN_LEFT in keys
and ecodes.REL_X in rels
)
self.__grabbed = False
def is_suitable(self) -> bool:
return (self.__has_keyboard or self.__has_mouse_rel)
def set_leds(self, caps: bool, scroll: bool, num: bool) -> None:
if self.__grabbed:
if self.__has_caps:
self.__device.set_led(ecodes.LED_CAPSL, caps)
if self.__has_scroll:
self.__device.set_led(ecodes.LED_SCROLLL, scroll)
if self.__has_num:
self.__device.set_led(ecodes.LED_NUML, num)
def set_grabbed(self, grabbed: bool) -> None:
if self.__grabbed != grabbed:
getattr(self.__device, ("grab" if grabbed else "ungrab"))()
self.__grabbed = grabbed
def close(self) -> None:
try:
self.__device.close()
except Exception:
pass
async def poll_to_queue(self, queue: asyncio.Queue[tuple[int, tuple]]) -> None:
def put(event: int, args: tuple) -> None:
queue.put_nowait((event, args))
move_x = move_y = 0
wheel_x = wheel_y = 0
async for event in self.__device.async_read_loop():
if not self.__grabbed:
# Клавиши перехватываются всегда для обработки хоткеев,
# всё остальное пропускается для экономии ресурсов.
if event.type == ecodes.EV_KEY and event.value != 2 and (event.code in ecodes.KEY):
put(self.KEY, (event.code, bool(event.value)))
continue
if event.type == ecodes.EV_REL:
match event.code:
case ecodes.REL_X:
move_x += event.value
case ecodes.REL_Y:
move_y += event.value
case ecodes.REL_HWHEEL:
wheel_x += event.value
case ecodes.REL_WHEEL:
wheel_y += event.value
if not self.__has_syn or event.type == ecodes.SYN_REPORT:
if move_x or move_y:
for xy in self.__splitted_deltas(move_x, move_y):
put(self.MOUSE_REL, xy)
move_x = move_y = 0
if wheel_x or wheel_y:
for xy in self.__splitted_deltas(wheel_x, wheel_y):
put(self.MOUSE_WHEEL, xy)
wheel_x = wheel_y = 0
elif event.type == ecodes.EV_KEY and event.value != 2:
if event.code in ecodes.KEY:
put(self.KEY, (event.code, bool(event.value)))
elif event.code in ecodes.BTN:
put(self.MOUSE_BUTTON, (event.code, bool(event.value)))
def __splitted_deltas(self, delta_x: int, delta_y: int) -> Generator[tuple[int, int], None, None]:
sign_x = (-1 if delta_x < 0 else 1)
sign_y = (-1 if delta_y < 0 else 1)
delta_x = abs(delta_x)
delta_y = abs(delta_y)
while delta_x > 0 or delta_y > 0:
dx = sign_x * max(min(delta_x, 127), 0)
dy = sign_y * max(min(delta_y, 127), 0)
yield (dx, dy)
delta_x -= 127
delta_y -= 127
def __str__(self) -> str:
info: list[str] = []
if self.__has_syn:
info.append("syn")
if self.__has_keyboard:
info.append("keyboard")
if self.__has_mouse_rel:
info.append("mouse_rel")
return f"Hid({self.__device.path!r}, {self.__device.name!r}, {self.__device.phys!r}, {', '.join(info)})"

178
kvmd/apps/localhid/multi.py Normal file
View File

@ -0,0 +1,178 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2020 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import asyncio
import dataclasses
import errno
from typing import AsyncGenerator
import pyudev
from ...logging import get_logger
from ... import aiotools
from .hid import Hid
# =====
def _udev_check(device: pyudev.Device) -> str:
props = device.properties
if props.get("ID_INPUT") == "1":
path = props.get("DEVNAME")
if isinstance(path, str) and path.startswith("/dev/input/event"):
return path
return ""
async def _follow_udev_hids() -> AsyncGenerator[tuple[bool, str], None]:
ctx = pyudev.Context()
monitor = pyudev.Monitor.from_netlink(pyudev.Context())
monitor.filter_by(subsystem="input")
monitor.start()
fd = monitor.fileno()
read_event = asyncio.Event()
loop = asyncio.get_event_loop()
loop.add_reader(fd, read_event.set)
try:
for device in ctx.list_devices(subsystem="input"):
path = _udev_check(device)
if path:
yield (True, path)
while True:
await read_event.wait()
while True:
device = monitor.poll(0)
if device is None:
read_event.clear()
break
path = _udev_check(device)
if path:
if device.action == "add":
yield (True, path)
elif device.action == "remove":
yield (False, path)
finally:
loop.remove_reader(fd)
@dataclasses.dataclass
class _Worker:
task: asyncio.Task
hid: (Hid | None)
class MultiHid:
def __init__(self, queue: asyncio.Queue[tuple[int, tuple]]) -> None:
self.__queue = queue
self.__workers: dict[str, _Worker] = {}
self.__grabbed = True
self.__leds = (False, False, False)
async def run(self) -> None:
logger = get_logger(0)
logger.info("Starting UDEV loop ...")
try:
async for (added, path) in _follow_udev_hids():
if added:
await self.__add_worker(path)
else:
await self.__remove_worker(path)
finally:
logger.info("Cleanup ...")
await aiotools.shield_fg(self.__cleanup())
async def __cleanup(self) -> None:
for path in list(self.__workers):
await self.__remove_worker(path)
async def __add_worker(self, path: str) -> None:
if path in self.__workers:
await self.__remove_worker(path)
self.__workers[path] = _Worker(asyncio.create_task(self.__worker_task_loop(path)), None)
async def __remove_worker(self, path: str) -> None:
if path not in self.__workers:
return
try:
worker = self.__workers[path]
worker.task.cancel()
await asyncio.gather(worker.task, return_exceptions=True)
except Exception:
pass
finally:
self.__workers.pop(path, None)
async def __worker_task_loop(self, path: str) -> None:
logger = get_logger(0)
while True:
hid: (Hid | None) = None
try:
hid = Hid(path)
if not hid.is_suitable():
break
logger.info("Opened: %s", hid)
if self.__grabbed:
hid.set_grabbed(True)
hid.set_leds(*self.__leds)
self.__workers[path].hid = hid
await hid.poll_to_queue(self.__queue)
except Exception as ex:
if isinstance(ex, OSError) and ex.errno == errno.ENODEV: # pylint: disable=no-member
logger.info("Closed: %s", hid)
break
logger.exception("Unhandled exception while polling %s", hid)
await asyncio.sleep(5)
finally:
self.__workers[path].hid = None
if hid:
hid.close()
def is_grabbed(self) -> bool:
return self.__grabbed
async def set_grabbed(self, grabbed: bool) -> None:
await aiotools.run_async(self.__inner_set_grabbed, grabbed)
def __inner_set_grabbed(self, grabbed: bool) -> None:
if self.__grabbed != grabbed:
get_logger(0).info("Grabbing ..." if grabbed else "Ungrabbing ...")
self.__grabbed = grabbed
for worker in self.__workers.values():
if worker.hid:
worker.hid.set_grabbed(grabbed)
self.__inner_set_leds(*self.__leds)
async def set_leds(self, caps: bool, scroll: bool, num: bool) -> None:
await aiotools.run_async(self.__inner_set_leds, caps, scroll, num)
def __inner_set_leds(self, caps: bool, scroll: bool, num: bool) -> None:
self.__leds = (caps, scroll, num)
if self.__grabbed:
for worker in self.__workers.values():
if worker.hid:
worker.hid.set_leds(*self.__leds)

View File

@ -0,0 +1,192 @@
# ========================================================================== #
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2020 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import asyncio
import errno
from typing import Callable
from typing import Coroutine
import aiohttp
import async_lru
from evdev import ecodes
from ...logging import get_logger
from ... import tools
from ... import aiotools
from ...keyboard.magic import MagicHandler
from ...clients.kvmd import KvmdClient
from ...clients.kvmd import KvmdClientSession
from ...clients.kvmd import KvmdClientWs
from .hid import Hid
from .multi import MultiHid
# =====
class LocalHidServer: # pylint: disable=too-many-instance-attributes
def __init__(self, kvmd: KvmdClient) -> None:
self.__kvmd = kvmd
self.__kvmd_session: (KvmdClientSession | None) = None
self.__kvmd_ws: (KvmdClientWs | None) = None
self.__queue: asyncio.Queue[tuple[int, tuple]] = asyncio.Queue()
self.__hid = MultiHid(self.__queue)
self.__info_switch_units = 0
self.__info_switch_active = ""
self.__info_mouse_absolute = True
self.__info_mouse_outputs: list[str] = []
self.__magic = MagicHandler(
proxy_handler=self.__on_magic_key_proxy,
key_handlers={
ecodes.KEY_H: self.__on_magic_grab,
ecodes.KEY_K: self.__on_magic_ungrab,
ecodes.KEY_UP: self.__on_magic_switch_prev,
ecodes.KEY_LEFT: self.__on_magic_switch_prev,
ecodes.KEY_DOWN: self.__on_magic_switch_next,
ecodes.KEY_RIGHT: self.__on_magic_switch_next,
},
numeric_handler=self.__on_magic_switch_port,
)
def run(self) -> None:
try:
aiotools.run(self.__inner_run())
finally:
get_logger(0).info("Bye-bye")
async def __inner_run(self) -> None:
await aiotools.spawn_and_follow(
self.__create_loop(self.__hid.run),
self.__create_loop(self.__queue_worker),
self.__create_loop(self.__api_worker),
)
async def __create_loop(self, func: Callable[[], Coroutine]) -> None:
while True:
try:
await func()
except Exception as ex:
if isinstance(ex, OSError) and ex.errno == errno.ENODEV: # pylint: disable=no-member
pass # Device disconnected
elif isinstance(ex, aiohttp.ClientError):
get_logger(0).error("KVMD client error: %s", tools.efmt(ex))
else:
get_logger(0).exception("Unhandled exception in the loop: %s", func)
await asyncio.sleep(5)
async def __queue_worker(self) -> None:
while True:
(event, args) = await self.__queue.get()
if event == Hid.KEY:
await self.__magic.handle_key(*args)
continue
elif self.__hid.is_grabbed() and self.__kvmd_session and self.__kvmd_ws:
match event:
case Hid.MOUSE_BUTTON:
await self.__kvmd_ws.send_mouse_button_event(*args)
case Hid.MOUSE_REL:
await self.__ensure_mouse_relative()
await self.__kvmd_ws.send_mouse_relative_event(*args)
case Hid.MOUSE_WHEEL:
await self.__kvmd_ws.send_mouse_wheel_event(*args)
async def __api_worker(self) -> None:
logger = get_logger(0)
async with self.__kvmd.make_session() as session:
async with session.ws(stream=False) as ws:
logger.info("KVMD session opened")
self.__kvmd_session = session
self.__kvmd_ws = ws
try:
async for (event_type, event) in ws.communicate():
if event_type == "hid":
if "leds" in event.get("keyboard", {}):
await self.__hid.set_leds(**event["keyboard"]["leds"])
if "absolute" in event.get("mouse", {}):
self.__info_mouse_outputs = event["mouse"]["outputs"]["available"]
self.__info_mouse_absolute = event["mouse"]["absolute"]
elif event_type == "switch":
if "model" in event:
self.__info_switch_units = len(event["model"]["units"])
if "summary" in event:
self.__info_switch_active = event["summary"]["active_id"]
finally:
logger.info("KVMD session closed")
self.__kvmd_session = None
self.__kvmd_ws = None
# =====
async def __ensure_mouse_relative(self) -> None:
if self.__info_mouse_absolute:
# Avoid unnecessary LRU checks, just to speed up a bit
await self.__inner_ensure_mouse_relative()
@async_lru.alru_cache(maxsize=1, ttl=1)
async def __inner_ensure_mouse_relative(self) -> None:
if self.__kvmd_session and self.__info_mouse_absolute:
for output in ["usb_rel", "ps2"]:
if output in self.__info_mouse_outputs:
await self.__kvmd_session.hid.set_params(mouse_output=output)
async def __on_magic_key_proxy(self, key: int, state: bool) -> None:
if self.__hid.is_grabbed() and self.__kvmd_ws:
await self.__kvmd_ws.send_key_event(key, state)
async def __on_magic_grab(self) -> None:
await self.__hid.set_grabbed(True)
async def __on_magic_ungrab(self) -> None:
await self.__hid.set_grabbed(False)
async def __on_magic_switch_prev(self) -> None:
if self.__kvmd_session and self.__info_switch_units > 0:
get_logger(0).info("Switching port to the previous one ...")
await self.__kvmd_session.switch.set_active_prev()
async def __on_magic_switch_next(self) -> None:
if self.__kvmd_session and self.__info_switch_units > 0:
get_logger(0).info("Switching port to the next one ...")
await self.__kvmd_session.switch.set_active_next()
async def __on_magic_switch_port(self, codes: list[int]) -> bool:
assert len(codes) > 0
if self.__info_switch_units <= 0:
return True
elif 1 <= self.__info_switch_units <= 2:
port = float(codes[0])
else: # self.__info_switch_units > 2:
if len(codes) == 1:
return False # Wait for the second key
port = (codes[0] + 1) + (codes[1] + 1) / 10
if self.__kvmd_session:
get_logger(0).info("Switching port to %s ...", port)
await self.__kvmd_session.switch.set_active(port)
return True

View File

@ -237,9 +237,9 @@ class KvmdClientSession(BaseHttpClientSession):
self.switch = _SwitchApiPart(self._ensure_http_session)
@contextlib.asynccontextmanager
async def ws(self) -> AsyncGenerator[KvmdClientWs, None]:
async def ws(self, stream: bool=True) -> AsyncGenerator[KvmdClientWs, None]:
session = self._ensure_http_session()
async with session.ws_connect("/ws", params={"legacy": "0"}) as ws:
async with session.ws_connect("/ws", params={"stream": int(stream)}) as ws:
yield KvmdClientWs(ws)

View File

@ -101,6 +101,7 @@ def main() -> None:
"kvmd.apps.ipmi",
"kvmd.apps.vnc",
"kvmd.apps.vnc.rfb",
"kvmd.apps.localhid",
"kvmd.apps.ngxmkconf",
"kvmd.apps.janus",
"kvmd.apps.watchdog",
@ -130,6 +131,7 @@ def main() -> None:
"kvmd-edidconf = kvmd.apps.edidconf:main",
"kvmd-ipmi = kvmd.apps.ipmi:main",
"kvmd-vnc = kvmd.apps.vnc:main",
"kvmd-localhid = kvmd.apps.localhid:main",
"kvmd-nginx-mkconf = kvmd.apps.ngxmkconf:main",
"kvmd-janus = kvmd.apps.janus:main",
"kvmd-watchdog = kvmd.apps.watchdog:main",