mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2025-12-12 01:00:29 +08:00
pikvm/pikvm#1485, pikvm/pikvm#187: kvmd-localhid to pass USB keyboard and mouse through PiKVM to the host
This commit is contained in:
parent
625b2aa970
commit
310b23edad
16
configs/os/services/kvmd-localhid.service
Normal file
16
configs/os/services/kvmd-localhid.service
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)),
|
||||
|
||||
45
kvmd/apps/localhid/__init__.py
Normal file
45
kvmd/apps/localhid/__init__.py
Normal 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()
|
||||
24
kvmd/apps/localhid/__main__.py
Normal file
24
kvmd/apps/localhid/__main__.py
Normal 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
152
kvmd/apps/localhid/hid.py
Normal 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
178
kvmd/apps/localhid/multi.py
Normal 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)
|
||||
192
kvmd/apps/localhid/server.py
Normal file
192
kvmd/apps/localhid/server.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
2
setup.py
2
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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user