From 310b23edad3cc7fd1b77718ad55472a280ce3794 Mon Sep 17 00:00:00 2001 From: Maxim Devaev Date: Fri, 23 May 2025 23:44:59 +0300 Subject: [PATCH] pikvm/pikvm#1485, pikvm/pikvm#187: kvmd-localhid to pass USB keyboard and mouse through PiKVM to the host --- configs/os/services/kvmd-localhid.service | 16 ++ configs/os/sysusers.conf | 6 + kvmd/aiotools.py | 12 ++ kvmd/apps/__init__.py | 7 + kvmd/apps/localhid/__init__.py | 45 +++++ kvmd/apps/localhid/__main__.py | 24 +++ kvmd/apps/localhid/hid.py | 152 +++++++++++++++++ kvmd/apps/localhid/multi.py | 178 ++++++++++++++++++++ kvmd/apps/localhid/server.py | 192 ++++++++++++++++++++++ kvmd/clients/kvmd.py | 4 +- setup.py | 2 + 11 files changed, 636 insertions(+), 2 deletions(-) create mode 100644 configs/os/services/kvmd-localhid.service create mode 100644 kvmd/apps/localhid/__init__.py create mode 100644 kvmd/apps/localhid/__main__.py create mode 100644 kvmd/apps/localhid/hid.py create mode 100644 kvmd/apps/localhid/multi.py create mode 100644 kvmd/apps/localhid/server.py diff --git a/configs/os/services/kvmd-localhid.service b/configs/os/services/kvmd-localhid.service new file mode 100644 index 00000000..792ea94e --- /dev/null +++ b/configs/os/services/kvmd-localhid.service @@ -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 diff --git a/configs/os/sysusers.conf b/configs/os/sysusers.conf index 96cdd09a..c7919304 100644 --- a/configs/os/sysusers.conf +++ b/configs/os/sysusers.conf @@ -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 diff --git a/kvmd/aiotools.py b/kvmd/aiotools.py index f400ad3c..656a10e6 100644 --- a/kvmd/aiotools.py +++ b/kvmd/aiotools.py @@ -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() diff --git a/kvmd/apps/__init__.py b/kvmd/apps/__init__.py index 8c174df4..e12e807e 100644 --- a/kvmd/apps/__init__.py +++ b/kvmd/apps/__init__.py @@ -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)), diff --git a/kvmd/apps/localhid/__init__.py b/kvmd/apps/localhid/__init__.py new file mode 100644 index 00000000..01b4d8b0 --- /dev/null +++ b/kvmd/apps/localhid/__init__.py @@ -0,0 +1,45 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +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() diff --git a/kvmd/apps/localhid/__main__.py b/kvmd/apps/localhid/__main__.py new file mode 100644 index 00000000..4827fc49 --- /dev/null +++ b/kvmd/apps/localhid/__main__.py @@ -0,0 +1,24 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +from . import main +main() diff --git a/kvmd/apps/localhid/hid.py b/kvmd/apps/localhid/hid.py new file mode 100644 index 00000000..fceb019d --- /dev/null +++ b/kvmd/apps/localhid/hid.py @@ -0,0 +1,152 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2018-2024 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +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)})" diff --git a/kvmd/apps/localhid/multi.py b/kvmd/apps/localhid/multi.py new file mode 100644 index 00000000..f131bc26 --- /dev/null +++ b/kvmd/apps/localhid/multi.py @@ -0,0 +1,178 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +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) diff --git a/kvmd/apps/localhid/server.py b/kvmd/apps/localhid/server.py new file mode 100644 index 00000000..da7196c4 --- /dev/null +++ b/kvmd/apps/localhid/server.py @@ -0,0 +1,192 @@ +# ========================================================================== # +# # +# KVMD - The main PiKVM daemon. # +# # +# Copyright (C) 2020 Maxim Devaev # +# # +# 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 . # +# # +# ========================================================================== # + + +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 diff --git a/kvmd/clients/kvmd.py b/kvmd/clients/kvmd.py index c1739525..497cdd3e 100644 --- a/kvmd/clients/kvmd.py +++ b/kvmd/clients/kvmd.py @@ -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) diff --git a/setup.py b/setup.py index fb2a4544..96af26e5 100755 --- a/setup.py +++ b/setup.py @@ -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",