From e17889ba426a167804796a830043e59e1941972f Mon Sep 17 00:00:00 2001 From: Devaev Maxim Date: Wed, 11 Sep 2019 06:28:04 +0300 Subject: plugin hid --- configs/kvmd/main/v0-hdmi.yaml | 6 +- configs/kvmd/main/v0-vga.yaml | 6 +- configs/kvmd/main/v1-hdmi.yaml | 6 +- configs/kvmd/main/v1-vga.yaml | 6 +- kvmd/apps/__init__.py | 19 +- kvmd/apps/cleanup/__init__.py | 4 +- kvmd/apps/kvmd/__init__.py | 5 +- kvmd/apps/kvmd/hid.py | 376 -------------------------------------- kvmd/apps/kvmd/server.py | 5 +- kvmd/plugins/hid/__init__.py | 67 +++++++ kvmd/plugins/hid/tty.py | 406 +++++++++++++++++++++++++++++++++++++++++ setup.py | 1 + testenv/main.yaml | 8 +- 13 files changed, 510 insertions(+), 405 deletions(-) delete mode 100644 kvmd/apps/kvmd/hid.py create mode 100644 kvmd/plugins/hid/__init__.py create mode 100644 kvmd/plugins/hid/tty.py diff --git a/configs/kvmd/main/v0-hdmi.yaml b/configs/kvmd/main/v0-hdmi.yaml index 2271c757..7137877a 100644 --- a/configs/kvmd/main/v0-hdmi.yaml +++ b/configs/kvmd/main/v0-hdmi.yaml @@ -15,8 +15,10 @@ kvmd: auth: !include auth.yaml hid: - reset_pin: 4 - device: /dev/kvmd-hid + type: tty + params: + reset_pin: 4 + device: /dev/kvmd-hid atx: power_led_pin: 24 diff --git a/configs/kvmd/main/v0-vga.yaml b/configs/kvmd/main/v0-vga.yaml index 9f2d895c..ac687336 100644 --- a/configs/kvmd/main/v0-vga.yaml +++ b/configs/kvmd/main/v0-vga.yaml @@ -15,8 +15,10 @@ kvmd: auth: !include auth.yaml hid: - reset_pin: 4 - device: /dev/kvmd-hid + type: tty + params: + reset_pin: 4 + device: /dev/kvmd-hid atx: power_led_pin: 24 diff --git a/configs/kvmd/main/v1-hdmi.yaml b/configs/kvmd/main/v1-hdmi.yaml index 6bfb1672..108e2da4 100644 --- a/configs/kvmd/main/v1-hdmi.yaml +++ b/configs/kvmd/main/v1-hdmi.yaml @@ -15,8 +15,10 @@ kvmd: auth: !include auth.yaml hid: - reset_pin: 4 - device: /dev/kvmd-hid + type: tty + params: + reset_pin: 4 + device: /dev/kvmd-hid atx: power_led_pin: 24 diff --git a/configs/kvmd/main/v1-vga.yaml b/configs/kvmd/main/v1-vga.yaml index 8593a377..c99d89c6 100644 --- a/configs/kvmd/main/v1-vga.yaml +++ b/configs/kvmd/main/v1-vga.yaml @@ -15,8 +15,10 @@ kvmd: auth: !include auth.yaml hid: - reset_pin: 4 - device: /dev/kvmd-hid + type: tty + params: + reset_pin: 4 + device: /dev/kvmd-hid atx: power_led_pin: 24 diff --git a/kvmd/apps/__init__.py b/kvmd/apps/__init__.py index 70c012db..974a7d70 100644 --- a/kvmd/apps/__init__.py +++ b/kvmd/apps/__init__.py @@ -37,6 +37,7 @@ import pygments.formatters from ..plugins import UnknownPluginError from ..plugins.auth import get_auth_service_class +from ..plugins.hid import get_hid_class from ..yamlconf import ConfigError from ..yamlconf import make_config @@ -64,7 +65,6 @@ from ..validators.net import valid_port from ..validators.kvm import valid_stream_quality from ..validators.kvm import valid_stream_fps -from ..validators.hw import valid_tty_speed from ..validators.hw import valid_gpio_pin from ..validators.hw import valid_gpio_pin_optional @@ -115,6 +115,9 @@ def _init_config(config_path: str, sections: List[str], override_options: List[s scheme["kvmd"]["auth"]["internal"] = get_auth_service_class(config.kvmd.auth.internal_type).get_plugin_options() if config.kvmd.auth.external_type: scheme["kvmd"]["auth"]["external"] = get_auth_service_class(config.kvmd.auth.external_type).get_plugin_options() + + scheme["kvmd"]["hid"]["params"] = get_hid_class(config.kvmd.hid.type).get_plugin_options() + config = make_config(raw_config, scheme) return config @@ -174,18 +177,8 @@ def _get_config_scheme(sections: List[str]) -> Dict: }, "hid": { - "reset_pin": Option(-1, type=valid_gpio_pin), - "reset_delay": Option(0.1, type=valid_float_f01), - - "device": Option("", type=valid_abs_path, unpack_as="device_path"), - "speed": Option(115200, type=valid_tty_speed), - "read_timeout": Option(2.0, type=valid_float_f01), - "read_retries": Option(10, type=valid_int_f1), - "common_retries": Option(100, type=valid_int_f1), - "retries_delay": Option(0.1, type=valid_float_f01), - "noop": Option(False, type=valid_bool), - - "state_poll": Option(0.1, type=valid_float_f01), + "type": Option("tty"), + # "params": {}, }, "atx": { diff --git a/kvmd/apps/cleanup/__init__.py b/kvmd/apps/cleanup/__init__.py index dff70510..8aaf3452 100644 --- a/kvmd/apps/cleanup/__init__.py +++ b/kvmd/apps/cleanup/__init__.py @@ -48,7 +48,9 @@ def main(argv: Optional[List[str]]=None) -> None: logger.info("Cleaning up ...") with gpio.bcm(): for (name, pin, enabled) in [ - ("hid_reset_pin", config.hid.reset_pin, True), + *([ + ("hid_reset_pin", config.hid.params.reset_pin, True), + ] if config.hid.type == "tty" else []), ("atx_power_switch_pin", config.atx.power_switch_pin, config.atx.enabled), ("atx_reset_switch_pin", config.atx.reset_switch_pin, config.atx.enabled), ("msd_target_pin", config.msd.target_pin, config.msd.enabled), diff --git a/kvmd/apps/kvmd/__init__.py b/kvmd/apps/kvmd/__init__.py index 4f60335a..203b741e 100644 --- a/kvmd/apps/kvmd/__init__.py +++ b/kvmd/apps/kvmd/__init__.py @@ -27,12 +27,13 @@ from ...logging import get_logger from ... import gpio +from ...plugins.hid import get_hid_class + from .. import init from .auth import AuthManager from .info import InfoManager from .logreader import LogReader -from .hid import Hid from .atx import Atx from .msd import MassStorageDevice from .streamer import Streamer @@ -61,7 +62,7 @@ def main(argv: Optional[List[str]]=None) -> None: info_manager=InfoManager(**config.info._unpack()), log_reader=LogReader(), - hid=Hid(**config.hid._unpack()), + hid=get_hid_class(config.hid.type)(**config.hid.params._unpack()), atx=Atx(**config.atx._unpack()), msd=MassStorageDevice(**config.msd._unpack()), streamer=Streamer(**config.streamer._unpack()), diff --git a/kvmd/apps/kvmd/hid.py b/kvmd/apps/kvmd/hid.py deleted file mode 100644 index 19ff71e3..00000000 --- a/kvmd/apps/kvmd/hid.py +++ /dev/null @@ -1,376 +0,0 @@ -# ========================================================================== # -# # -# KVMD - The main Pi-KVM daemon. # -# # -# Copyright (C) 2018 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 os -import signal -import asyncio -import dataclasses -import multiprocessing -import multiprocessing.queues -import queue -import struct -import errno -import time - -from typing import Dict -from typing import Set -from typing import AsyncGenerator - -import serial -import setproctitle - -from ...logging import get_logger - -from ... import aiotools -from ... import gpio -from ... import keymap - - -# ===== -class _BaseEvent: - def make_command(self) -> bytes: - raise NotImplementedError - - -@dataclasses.dataclass(frozen=True) # pylint: disable=abstract-method -class _BoolEvent(_BaseEvent): - name: str - state: bool - - -@dataclasses.dataclass(frozen=True) # pylint: disable=abstract-method -class _IntEvent(_BaseEvent): - x: int - y: int - - -@dataclasses.dataclass(frozen=True) -class _KeyEvent(_BoolEvent): - def __post_init__(self) -> None: - assert self.name in keymap.KEYMAP - - def make_command(self) -> bytes: - code = keymap.KEYMAP[self.name] - key_bytes = bytes([code]) - assert len(key_bytes) == 1, (self, key_bytes, code) - state_bytes = (b"\x01" if self.state else b"\x00") - return b"\x11" + key_bytes + state_bytes + b"\x00\x00" - - -@dataclasses.dataclass(frozen=True) -class _MouseMoveEvent(_IntEvent): - def __post_init__(self) -> None: - assert -32768 <= self.x <= 32767 - assert -32768 <= self.y <= 32767 - - def make_command(self) -> bytes: - return b"\x12" + struct.pack(">hh", self.x, self.y) - - -@dataclasses.dataclass(frozen=True) -class _MouseButtonEvent(_BoolEvent): - def __post_init__(self) -> None: - assert self.name in ["left", "right"] - - def make_command(self) -> bytes: - code = 0 - if self.name == "left": - code = (0b10000000 | (0b00001000 if self.state else 0)) - elif self.name == "right": - code = (0b01000000 | (0b00000100 if self.state else 0)) - assert code, self - return b"\x13" + bytes([code]) + b"\x00\x00\x00" - - -@dataclasses.dataclass(frozen=True) -class _MouseWheelEvent(_IntEvent): - def __post_init__(self) -> None: - assert self.x == 0 # Горизонтальная прокрутка пока не поддерживается - assert -128 <= self.y <= 127 - - def make_command(self) -> bytes: - return b"\x14\x00" + struct.pack(">b", self.y) + b"\x00\x00" - - -# ===== -class Hid(multiprocessing.Process): # pylint: disable=too-many-instance-attributes - def __init__( # pylint: disable=too-many-arguments - self, - reset_pin: int, - reset_delay: float, - - device_path: str, - speed: int, - read_timeout: float, - read_retries: int, - common_retries: int, - retries_delay: float, - noop: bool, - - state_poll: float, - ) -> None: - - super().__init__(daemon=True) - - self.__reset_pin = gpio.set_output(reset_pin) - self.__reset_delay = reset_delay - - self.__device_path = device_path - self.__speed = speed - self.__read_timeout = read_timeout - self.__read_retries = read_retries - self.__common_retries = common_retries - self.__retries_delay = retries_delay - self.__noop = noop - - self.__state_poll = state_poll - - self.__lock = asyncio.Lock() - - self.__pressed_keys: Set[str] = set() - self.__pressed_mouse_buttons: Set[str] = set() - self.__events_queue: multiprocessing.queues.Queue = multiprocessing.Queue() - - self.__online_shared = multiprocessing.Value("i", 1) - self.__stop_event = multiprocessing.Event() - - def start(self) -> None: - get_logger().info("Starting HID daemon ...") - super().start() - - def get_state(self) -> Dict: - return {"online": bool(self.__online_shared.value)} - - async def poll_state(self) -> AsyncGenerator[Dict, None]: - prev_state: Dict = {} - while self.is_alive(): - state = self.get_state() - if state != prev_state: - yield self.get_state() - prev_state = state - await asyncio.sleep(self.__state_poll) - - @aiotools.atomic - async def reset(self) -> None: - async with aiotools.unlock_only_on_exception(self.__lock): - await self.__inner_reset() - - @aiotools.tasked - @aiotools.muted("Can't reset HID or operation was not completed") - async def __inner_reset(self) -> None: - try: - gpio.write(self.__reset_pin, True) - await asyncio.sleep(self.__reset_delay) - finally: - try: - gpio.write(self.__reset_pin, False) - await asyncio.sleep(1) - finally: - self.__lock.release() - get_logger(0).info("Reset HID performed") - - async def send_key_event(self, key: str, state: bool) -> None: - await self.__send_bool_event(_KeyEvent(key, state), self.__pressed_keys) - - async def send_mouse_move_event(self, to_x: int, to_y: int) -> None: - await self.__send_int_event(_MouseMoveEvent(to_x, to_y)) - - async def send_mouse_button_event(self, button: str, state: bool) -> None: - await self.__send_bool_event(_MouseButtonEvent(button, state), self.__pressed_mouse_buttons) - - async def send_mouse_wheel_event(self, delta_y: int) -> None: - await self.__send_int_event(_MouseWheelEvent(0, delta_y)) - - async def clear_events(self) -> None: - if not self.__stop_event.is_set(): - async with self.__lock: - self.__unsafe_clear_events() - - @aiotools.atomic - async def cleanup(self) -> None: - logger = get_logger(0) - async with self.__lock: - try: - if self.is_alive(): - self.__unsafe_clear_events() - logger.info("Stopping HID daemon ...") - self.__stop_event.set() - else: - logger.warning("Emergency cleaning up HID events ...") - self.__emergency_clear_events() - if self.exitcode is not None: - self.join() - finally: - gpio.write(self.__reset_pin, False) - - async def __send_bool_event(self, event: _BoolEvent, pressed: Set[str]) -> None: - if not self.__stop_event.is_set(): - async with self.__lock: - if ( - (event.state and (event.name not in pressed)) # Если еще не нажато - or (not event.state and (event.name in pressed)) # ... Или еще не отжато - ): - if event.state: - pressed.add(event.name) - else: - pressed.remove(event.name) - self.__events_queue.put(event) - - async def __send_int_event(self, event: _IntEvent) -> None: - if not self.__stop_event.is_set(): - async with self.__lock: - self.__events_queue.put(event) - - def __unsafe_clear_events(self) -> None: - for (cls, pressed) in [ - (_MouseButtonEvent, self.__pressed_mouse_buttons), - (_KeyEvent, self.__pressed_keys), - ]: - for name in pressed: - self.__events_queue.put(cls(name, False)) - pressed.clear() - - def __emergency_clear_events(self) -> None: - if os.path.exists(self.__device_path): - try: - with self.__get_serial() as tty: - self.__process_command(tty, b"\x10\x00\x00\x00\x00") - except Exception: - get_logger().exception("Can't execute emergency clear HID events") - - def run(self) -> None: # pylint: disable=too-many-branches - logger = get_logger(0) - - logger.info("Started HID pid=%d", os.getpid()) - signal.signal(signal.SIGINT, signal.SIG_IGN) - setproctitle.setproctitle("[hid] " + setproctitle.getproctitle()) - - while not self.__stop_event.is_set(): - try: - with self.__get_serial() as tty: - passed = 0 - while not (self.__stop_event.is_set() and self.__events_queue.qsize() == 0): - try: - event: _BaseEvent = self.__events_queue.get(timeout=0.05) - except queue.Empty: - if passed >= 20: # 20 * 0.05 = 1 sec - self.__process_command(tty, b"\x01\x00\x00\x00\x00") # Ping - passed = 0 - else: - passed += 1 - else: - self.__process_command(tty, event.make_command()) - passed = 0 - - except serial.SerialException as err: - if err.errno == errno.ENOENT: - logger.error("Missing HID serial device: %s", self.__device_path) - else: - logger.exception("Unexpected HID error") - - except Exception: - logger.exception("Unexpected HID error") - - finally: - time.sleep(1) - - def __get_serial(self) -> serial.Serial: - return serial.Serial(self.__device_path, self.__speed, timeout=self.__read_timeout) - - def __process_command(self, tty: serial.Serial, command: bytes) -> None: - self.__process_request(tty, self.__make_request(command)) - - def __process_request(self, tty: serial.Serial, request: bytes) -> None: # pylint: disable=too-many-branches - logger = get_logger() - - common_retries = self.__common_retries - read_retries = self.__read_retries - error_occured = False - - while common_retries and read_retries: - if not self.__noop: - if tty.in_waiting: - tty.read(tty.in_waiting) - - assert tty.write(request) == len(request) - response = tty.read(4) - else: - response = b"\x33\x20" # Magic + OK - response += struct.pack(">H", self.__make_crc16(response)) - - if len(response) < 4: - logger.error("No response from HID: request=%r", request) - read_retries -= 1 - else: - assert len(response) == 4, response - if self.__make_crc16(response[-4:-2]) != struct.unpack(">H", response[-2:])[0]: - get_logger().error("Invalid response CRC; requesting response again ...") - request = self.__make_request(b"\x02\x00\x00\x00\x00") # Repeat an answer - else: - code = response[1] - if code == 0x48: # Request timeout - logger.error("Got request timeout from HID: request=%r", request) - elif code == 0x40: # CRC Error - logger.error("Got CRC error of request from HID: request=%r", request) - elif code == 0x45: # Unknown command - logger.error("HID did not recognize the request=%r", request) - self.__online_shared.value = 1 - return - elif code == 0x24: # Rebooted? - logger.error("No previous command state inside HID, seems it was rebooted") - self.__online_shared.value = 1 - return - elif code == 0x20: # Done - if error_occured: - logger.info("Success!") - self.__online_shared.value = 1 - return - else: - logger.error("Invalid response from HID: request=%r; code=0x%x", request, code) - - common_retries -= 1 - error_occured = True - self.__online_shared.value = 0 - - if common_retries and read_retries: - logger.error("Retries left: common_retries=%d; read_retries=%d", common_retries, read_retries) - time.sleep(self.__retries_delay) - - logger.error("Can't process HID request due many errors: %r", request) - - def __make_request(self, command: bytes) -> bytes: - request = b"\x33" + command - request += struct.pack(">H", self.__make_crc16(request)) - assert len(request) == 8, (request, command) - return request - - def __make_crc16(self, data: bytes) -> int: - crc = 0xFFFF - for byte in data: - crc = crc ^ byte - for _ in range(8): - if crc & 0x0001 == 0: - crc = crc >> 1 - else: - crc = crc >> 1 - crc = crc ^ 0xA001 - return crc diff --git a/kvmd/apps/kvmd/server.py b/kvmd/apps/kvmd/server.py index 5ea1fd62..1c8461ae 100644 --- a/kvmd/apps/kvmd/server.py +++ b/kvmd/apps/kvmd/server.py @@ -44,6 +44,8 @@ from ...logging import get_logger from ...aioregion import RegionIsBusyError +from ...plugins.hid import BaseHid + from ...validators import ValidatorError from ...validators.basic import valid_bool @@ -70,7 +72,6 @@ from ... import __version__ from .auth import AuthManager from .info import InfoManager from .logreader import LogReader -from .hid import Hid from .atx import AtxOperationError from .atx import Atx from .msd import MsdOperationError @@ -230,7 +231,7 @@ class Server: # pylint: disable=too-many-instance-attributes info_manager: InfoManager, log_reader: LogReader, - hid: Hid, + hid: BaseHid, atx: Atx, msd: MassStorageDevice, streamer: Streamer, diff --git a/kvmd/plugins/hid/__init__.py b/kvmd/plugins/hid/__init__.py new file mode 100644 index 00000000..cbab9775 --- /dev/null +++ b/kvmd/plugins/hid/__init__.py @@ -0,0 +1,67 @@ +# ========================================================================== # +# # +# KVMD - The main Pi-KVM daemon. # +# # +# Copyright (C) 2018 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 typing import Dict +from typing import AsyncGenerator +from typing import Type + +from .. import BasePlugin +from .. import get_plugin_class + + +# ===== +class BaseHid(BasePlugin): + def start(self) -> None: + pass + + def get_state(self) -> Dict: + raise NotImplementedError + + async def poll_state(self) -> AsyncGenerator[Dict, None]: + yield {} + raise NotImplementedError + + async def reset(self) -> None: + raise NotImplementedError + + async def send_key_event(self, key: str, state: bool) -> None: + raise NotImplementedError + + async def send_mouse_move_event(self, to_x: int, to_y: int) -> None: + raise NotImplementedError + + async def send_mouse_button_event(self, button: str, state: bool) -> None: + raise NotImplementedError + + async def send_mouse_wheel_event(self, delta_y: int) -> None: + raise NotImplementedError + + async def clear_events(self) -> None: + raise NotImplementedError + + async def cleanup(self) -> None: + pass + + +# ===== +def get_hid_class(name: str) -> Type[BaseHid]: + return get_plugin_class("hid", name) # type: ignore diff --git a/kvmd/plugins/hid/tty.py b/kvmd/plugins/hid/tty.py new file mode 100644 index 00000000..18930504 --- /dev/null +++ b/kvmd/plugins/hid/tty.py @@ -0,0 +1,406 @@ +# ========================================================================== # +# # +# KVMD - The main Pi-KVM daemon. # +# # +# Copyright (C) 2018 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 os +import signal +import asyncio +import dataclasses +import multiprocessing +import multiprocessing.queues +import queue +import struct +import errno +import time + +from typing import Dict +from typing import Set +from typing import AsyncGenerator + +import serial +import setproctitle + +from ...logging import get_logger + +from ... import aiotools +from ... import gpio +from ... import keymap + +from ...yamlconf import Option + +from ...validators.basic import valid_bool +from ...validators.basic import valid_int_f1 +from ...validators.basic import valid_float_f01 + +from ...validators.os import valid_abs_path + +from ...validators.hw import valid_tty_speed +from ...validators.hw import valid_gpio_pin + +from . import BaseHid + + +# ===== +class _BaseEvent: + def make_command(self) -> bytes: + raise NotImplementedError + + +@dataclasses.dataclass(frozen=True) # pylint: disable=abstract-method +class _BoolEvent(_BaseEvent): + name: str + state: bool + + +@dataclasses.dataclass(frozen=True) # pylint: disable=abstract-method +class _IntEvent(_BaseEvent): + x: int + y: int + + +@dataclasses.dataclass(frozen=True) +class _KeyEvent(_BoolEvent): + def __post_init__(self) -> None: + assert self.name in keymap.KEYMAP + + def make_command(self) -> bytes: + code = keymap.KEYMAP[self.name] + key_bytes = bytes([code]) + assert len(key_bytes) == 1, (self, key_bytes, code) + state_bytes = (b"\x01" if self.state else b"\x00") + return b"\x11" + key_bytes + state_bytes + b"\x00\x00" + + +@dataclasses.dataclass(frozen=True) +class _MouseMoveEvent(_IntEvent): + def __post_init__(self) -> None: + assert -32768 <= self.x <= 32767 + assert -32768 <= self.y <= 32767 + + def make_command(self) -> bytes: + return b"\x12" + struct.pack(">hh", self.x, self.y) + + +@dataclasses.dataclass(frozen=True) +class _MouseButtonEvent(_BoolEvent): + def __post_init__(self) -> None: + assert self.name in ["left", "right"] + + def make_command(self) -> bytes: + code = 0 + if self.name == "left": + code = (0b10000000 | (0b00001000 if self.state else 0)) + elif self.name == "right": + code = (0b01000000 | (0b00000100 if self.state else 0)) + assert code, self + return b"\x13" + bytes([code]) + b"\x00\x00\x00" + + +@dataclasses.dataclass(frozen=True) +class _MouseWheelEvent(_IntEvent): + def __post_init__(self) -> None: + assert self.x == 0 # Горизонтальная прокрутка пока не поддерживается + assert -128 <= self.y <= 127 + + def make_command(self) -> bytes: + return b"\x14\x00" + struct.pack(">b", self.y) + b"\x00\x00" + + +# ===== +class Plugin(BaseHid, multiprocessing.Process): # pylint: disable=too-many-instance-attributes + def __init__( # pylint: disable=too-many-arguments,super-init-not-called + self, + reset_pin: int, + reset_delay: float, + + device_path: str, + speed: int, + read_timeout: float, + read_retries: int, + common_retries: int, + retries_delay: float, + noop: bool, + + state_poll: float, + ) -> None: + + multiprocessing.Process.__init__(self, daemon=True) + + self.__reset_pin = gpio.set_output(reset_pin) + self.__reset_delay = reset_delay + + self.__device_path = device_path + self.__speed = speed + self.__read_timeout = read_timeout + self.__read_retries = read_retries + self.__common_retries = common_retries + self.__retries_delay = retries_delay + self.__noop = noop + + self.__state_poll = state_poll + + self.__lock = asyncio.Lock() + + self.__pressed_keys: Set[str] = set() + self.__pressed_mouse_buttons: Set[str] = set() + self.__events_queue: multiprocessing.queues.Queue = multiprocessing.Queue() + + self.__online_shared = multiprocessing.Value("i", 1) + self.__stop_event = multiprocessing.Event() + + @classmethod + def get_plugin_options(cls) -> Dict[str, Option]: + return { + "reset_pin": Option(-1, type=valid_gpio_pin), + "reset_delay": Option(0.1, type=valid_float_f01), + + "device": Option("", type=valid_abs_path, unpack_as="device_path"), + "speed": Option(115200, type=valid_tty_speed), + "read_timeout": Option(2.0, type=valid_float_f01), + "read_retries": Option(10, type=valid_int_f1), + "common_retries": Option(100, type=valid_int_f1), + "retries_delay": Option(0.1, type=valid_float_f01), + "noop": Option(False, type=valid_bool), + + "state_poll": Option(0.1, type=valid_float_f01), + } + + def start(self) -> None: + get_logger().info("Starting HID daemon ...") + multiprocessing.Process.start(self) + + def get_state(self) -> Dict: + return {"online": bool(self.__online_shared.value)} + + async def poll_state(self) -> AsyncGenerator[Dict, None]: + prev_state: Dict = {} + while self.is_alive(): + state = self.get_state() + if state != prev_state: + yield self.get_state() + prev_state = state + await asyncio.sleep(self.__state_poll) + + @aiotools.atomic + async def reset(self) -> None: + async with aiotools.unlock_only_on_exception(self.__lock): + await self.__inner_reset() + + @aiotools.tasked + @aiotools.muted("Can't reset HID or operation was not completed") + async def __inner_reset(self) -> None: + try: + gpio.write(self.__reset_pin, True) + await asyncio.sleep(self.__reset_delay) + finally: + try: + gpio.write(self.__reset_pin, False) + await asyncio.sleep(1) + finally: + self.__lock.release() + get_logger(0).info("Reset HID performed") + + async def send_key_event(self, key: str, state: bool) -> None: + await self.__send_bool_event(_KeyEvent(key, state), self.__pressed_keys) + + async def send_mouse_move_event(self, to_x: int, to_y: int) -> None: + await self.__send_int_event(_MouseMoveEvent(to_x, to_y)) + + async def send_mouse_button_event(self, button: str, state: bool) -> None: + await self.__send_bool_event(_MouseButtonEvent(button, state), self.__pressed_mouse_buttons) + + async def send_mouse_wheel_event(self, delta_y: int) -> None: + await self.__send_int_event(_MouseWheelEvent(0, delta_y)) + + async def clear_events(self) -> None: + if not self.__stop_event.is_set(): + async with self.__lock: + self.__unsafe_clear_events() + + @aiotools.atomic + async def cleanup(self) -> None: + logger = get_logger(0) + async with self.__lock: + try: + if self.is_alive(): + self.__unsafe_clear_events() + logger.info("Stopping HID daemon ...") + self.__stop_event.set() + else: + logger.warning("Emergency cleaning up HID events ...") + self.__emergency_clear_events() + if self.exitcode is not None: + self.join() + finally: + gpio.write(self.__reset_pin, False) + + async def __send_bool_event(self, event: _BoolEvent, pressed: Set[str]) -> None: + if not self.__stop_event.is_set(): + async with self.__lock: + if ( + (event.state and (event.name not in pressed)) # Если еще не нажато + or (not event.state and (event.name in pressed)) # ... Или еще не отжато + ): + if event.state: + pressed.add(event.name) + else: + pressed.remove(event.name) + self.__events_queue.put(event) + + async def __send_int_event(self, event: _IntEvent) -> None: + if not self.__stop_event.is_set(): + async with self.__lock: + self.__events_queue.put(event) + + def __unsafe_clear_events(self) -> None: + for (cls, pressed) in [ + (_MouseButtonEvent, self.__pressed_mouse_buttons), + (_KeyEvent, self.__pressed_keys), + ]: + for name in pressed: + self.__events_queue.put(cls(name, False)) + pressed.clear() + + def __emergency_clear_events(self) -> None: + if os.path.exists(self.__device_path): + try: + with self.__get_serial() as tty: + self.__process_command(tty, b"\x10\x00\x00\x00\x00") + except Exception: + get_logger().exception("Can't execute emergency clear HID events") + + def run(self) -> None: # pylint: disable=too-many-branches + logger = get_logger(0) + + logger.info("Started HID pid=%d", os.getpid()) + signal.signal(signal.SIGINT, signal.SIG_IGN) + setproctitle.setproctitle("[hid] " + setproctitle.getproctitle()) + + while not self.__stop_event.is_set(): + try: + with self.__get_serial() as tty: + passed = 0 + while not (self.__stop_event.is_set() and self.__events_queue.qsize() == 0): + try: + event: _BaseEvent = self.__events_queue.get(timeout=0.05) + except queue.Empty: + if passed >= 20: # 20 * 0.05 = 1 sec + self.__process_command(tty, b"\x01\x00\x00\x00\x00") # Ping + passed = 0 + else: + passed += 1 + else: + self.__process_command(tty, event.make_command()) + passed = 0 + + except serial.SerialException as err: + if err.errno == errno.ENOENT: + logger.error("Missing HID serial device: %s", self.__device_path) + else: + logger.exception("Unexpected HID error") + + except Exception: + logger.exception("Unexpected HID error") + + finally: + time.sleep(1) + + def __get_serial(self) -> serial.Serial: + return serial.Serial(self.__device_path, self.__speed, timeout=self.__read_timeout) + + def __process_command(self, tty: serial.Serial, command: bytes) -> None: + self.__process_request(tty, self.__make_request(command)) + + def __process_request(self, tty: serial.Serial, request: bytes) -> None: # pylint: disable=too-many-branches + logger = get_logger() + + common_retries = self.__common_retries + read_retries = self.__read_retries + error_occured = False + + while common_retries and read_retries: + if not self.__noop: + if tty.in_waiting: + tty.read(tty.in_waiting) + + assert tty.write(request) == len(request) + response = tty.read(4) + else: + response = b"\x33\x20" # Magic + OK + response += struct.pack(">H", self.__make_crc16(response)) + + if len(response) < 4: + logger.error("No response from HID: request=%r", request) + read_retries -= 1 + else: + assert len(response) == 4, response + if self.__make_crc16(response[-4:-2]) != struct.unpack(">H", response[-2:])[0]: + get_logger().error("Invalid response CRC; requesting response again ...") + request = self.__make_request(b"\x02\x00\x00\x00\x00") # Repeat an answer + else: + code = response[1] + if code == 0x48: # Request timeout + logger.error("Got request timeout from HID: request=%r", request) + elif code == 0x40: # CRC Error + logger.error("Got CRC error of request from HID: request=%r", request) + elif code == 0x45: # Unknown command + logger.error("HID did not recognize the request=%r", request) + self.__online_shared.value = 1 + return + elif code == 0x24: # Rebooted? + logger.error("No previous command state inside HID, seems it was rebooted") + self.__online_shared.value = 1 + return + elif code == 0x20: # Done + if error_occured: + logger.info("Success!") + self.__online_shared.value = 1 + return + else: + logger.error("Invalid response from HID: request=%r; code=0x%x", request, code) + + common_retries -= 1 + error_occured = True + self.__online_shared.value = 0 + + if common_retries and read_retries: + logger.error("Retries left: common_retries=%d; read_retries=%d", common_retries, read_retries) + time.sleep(self.__retries_delay) + + logger.error("Can't process HID request due many errors: %r", request) + + def __make_request(self, command: bytes) -> bytes: + request = b"\x33" + command + request += struct.pack(">H", self.__make_crc16(request)) + assert len(request) == 8, (request, command) + return request + + def __make_crc16(self, data: bytes) -> int: + crc = 0xFFFF + for byte in data: + crc = crc ^ byte + for _ in range(8): + if crc & 0x0001 == 0: + crc = crc >> 1 + else: + crc = crc >> 1 + crc = crc ^ 0xA001 + return crc diff --git a/setup.py b/setup.py index d347706e..44cd500a 100755 --- a/setup.py +++ b/setup.py @@ -81,6 +81,7 @@ def main() -> None: "kvmd.yamlconf", "kvmd.plugins", "kvmd.plugins.auth", + "kvmd.plugins.hid", "kvmd.apps", "kvmd.apps.kvmd", "kvmd.apps.htpasswd", diff --git a/testenv/main.yaml b/testenv/main.yaml index 4ff88d94..6c6c9bbd 100644 --- a/testenv/main.yaml +++ b/testenv/main.yaml @@ -7,9 +7,11 @@ kvmd: auth: !include auth.yaml hid: - reset_pin: 4 - device: /dev/ttyS10 - noop: true + type: tty + params: + reset_pin: 4 + device: /dev/ttyS10 + noop: true atx: power_led_pin: 24 -- cgit v1.2.3