summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--kvmd/apps/otg/hid/keyboard.py7
-rw-r--r--kvmd/apps/otg/hid/mouse.py16
-rw-r--r--kvmd/plugins/hid/bt/__init__.py208
-rw-r--r--kvmd/plugins/hid/bt/bluez.py110
-rw-r--r--kvmd/plugins/hid/bt/sdp.py152
-rw-r--r--kvmd/plugins/hid/bt/server.py390
-rwxr-xr-xsetup.py1
7 files changed, 879 insertions, 5 deletions
diff --git a/kvmd/apps/otg/hid/keyboard.py b/kvmd/apps/otg/hid/keyboard.py
index c38a2bfb..a098c6de 100644
--- a/kvmd/apps/otg/hid/keyboard.py
+++ b/kvmd/apps/otg/hid/keyboard.py
@@ -20,11 +20,13 @@
# ========================================================================== #
+from typing import Optional
+
from . import Hid
# =====
-def make_keyboard_hid() -> Hid:
+def make_keyboard_hid(report_id: Optional[int]=None) -> Hid:
return Hid(
protocol=1, # Keyboard protocol
subclass=1, # Boot interface subclass
@@ -40,6 +42,9 @@ def make_keyboard_hid() -> Hid:
0x09, 0x06, # USAGE (Keyboard)
0xA1, 0x01, # COLLECTION (Application)
+ # Report ID
+ *([0x85, report_id] if report_id is not None else []),
+
# Modifiers
0x05, 0x07, # USAGE_PAGE (Keyboard)
0x19, 0xE0, # USAGE_MINIMUM (Keyboard LeftControl)
diff --git a/kvmd/apps/otg/hid/mouse.py b/kvmd/apps/otg/hid/mouse.py
index 9dcc0e5d..b1d235a2 100644
--- a/kvmd/apps/otg/hid/mouse.py
+++ b/kvmd/apps/otg/hid/mouse.py
@@ -20,13 +20,15 @@
# ========================================================================== #
+from typing import Optional
+
from . import Hid
# =====
-def make_mouse_hid(absolute: bool, horizontal_wheel: bool) -> Hid:
+def make_mouse_hid(absolute: bool, horizontal_wheel: bool, report_id: Optional[int]=None) -> Hid:
maker = (_make_absolute_hid if absolute else _make_relative_hid)
- return maker(horizontal_wheel)
+ return maker(horizontal_wheel, report_id)
_HORIZONTAL_WHEEL = [
@@ -40,7 +42,7 @@ _HORIZONTAL_WHEEL = [
]
-def _make_absolute_hid(horizontal_wheel: bool) -> Hid:
+def _make_absolute_hid(horizontal_wheel: bool, report_id: Optional[int]) -> Hid:
return Hid(
protocol=0, # None protocol
subclass=0, # No subclass
@@ -60,6 +62,9 @@ def _make_absolute_hid(horizontal_wheel: bool) -> Hid:
0x09, 0x02, # USAGE (Mouse)
0xA1, 0x01, # COLLECTION (Application)
+ # Report ID
+ *([0x85, report_id] if report_id is not None else []),
+
# 8 Buttons
0x05, 0x09, # USAGE_PAGE (Button)
0x19, 0x01, # USAGE_MINIMUM (Button 1)
@@ -96,7 +101,7 @@ def _make_absolute_hid(horizontal_wheel: bool) -> Hid:
)
-def _make_relative_hid(horizontal_wheel: bool) -> Hid:
+def _make_relative_hid(horizontal_wheel: bool, report_id: Optional[int]) -> Hid:
return Hid(
protocol=2, # Mouse protocol
subclass=1, # Boot interface subclass
@@ -111,6 +116,9 @@ def _make_relative_hid(horizontal_wheel: bool) -> Hid:
0x09, 0x02, # USAGE (Mouse)
0xA1, 0x01, # COLLECTION (Application)
+ # Report ID
+ *([0x85, report_id] if report_id is not None else []),
+
# 8 Buttons
0x05, 0x09, # USAGE_PAGE (Button)
0x19, 0x01, # USAGE_MINIMUM (Button 1)
diff --git a/kvmd/plugins/hid/bt/__init__.py b/kvmd/plugins/hid/bt/__init__.py
new file mode 100644
index 00000000..6c8dc46b
--- /dev/null
+++ b/kvmd/plugins/hid/bt/__init__.py
@@ -0,0 +1,208 @@
+# ========================================================================== #
+# #
+# KVMD - The main Pi-KVM daemon. #
+# #
+# Copyright (C) 2018 Maxim Devaev <[email protected]> #
+# #
+# 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 os
+import multiprocessing
+import time
+
+from typing import Tuple
+from typing import Dict
+from typing import Iterable
+from typing import AsyncGenerator
+from typing import Optional
+
+from ....logging import get_logger
+
+from ....yamlconf import Option
+
+from ....validators.basic import valid_bool
+from ....validators.basic import valid_stripped_string_not_empty
+from ....validators.basic import valid_int_f1
+from ....validators.basic import valid_float_f01
+
+from .... import aiotools
+from .... import aiomulti
+from .... import aioproc
+
+from .. import BaseHid
+
+from ..otg.events import ResetEvent
+from ..otg.events import make_keyboard_event
+from ..otg.events import MouseButtonEvent
+from ..otg.events import MouseRelativeEvent
+from ..otg.events import MouseWheelEvent
+
+from .sdp import make_sdp_record
+from .bluez import BluezIface
+from .server import BtServer
+
+
+# =====
+class Plugin(BaseHid): # pylint: disable=too-many-instance-attributes
+ # https://github.com/SySS-Research/bluetooth-keyboard-emulator
+ # https://github.com/nutki/bt-keyboard-switcher
+ # https://gist.github.com/whitelynx/9f9bd4cb266b3924c64dfdff14bce2e8
+ # https://archlinuxarm.org/forum/viewtopic.php?f=67&t=14244
+
+ def __init__( # pylint: disable=too-many-arguments,too-many-locals,super-init-not-called
+ self,
+ manufacturer: str,
+ product: str,
+ description: str,
+
+ iface: str,
+ alias: str,
+
+ pairing_required: bool,
+ auth_required: bool,
+ control_public: bool,
+ unpair_on_close: bool,
+
+ max_clients: int,
+ socket_timeout: float,
+ select_timeout: float,
+ ) -> None:
+
+ self.__proc: Optional[multiprocessing.Process] = None
+ self.__stop_event = multiprocessing.Event()
+
+ self.__notifier = aiomulti.AioProcessNotifier()
+
+ self.__server = BtServer(
+ iface=BluezIface(
+ iface=iface,
+ alias=alias,
+ sdp_record=make_sdp_record(manufacturer, product, description),
+ pairing_required=pairing_required,
+ auth_required=auth_required,
+ ),
+ control_public=control_public,
+ unpair_on_close=unpair_on_close,
+ max_clients=max_clients,
+ socket_timeout=socket_timeout,
+ select_timeout=select_timeout,
+ notifier=self.__notifier,
+ stop_event=self.__stop_event,
+ )
+
+ @classmethod
+ def get_plugin_options(cls) -> Dict:
+ return {
+ "manufacturer": Option("Pi-KVM"),
+ "product": Option("HID Device"),
+ "description": Option("Bluetooth Keyboard & Mouse"),
+
+ "iface": Option("hci0", type=valid_stripped_string_not_empty),
+ "alias": Option("Pi-KVM HID"),
+
+ "pairing_required": Option(True, type=valid_bool),
+ "auth_required": Option(False, type=valid_bool),
+ "control_public": Option(True, type=valid_bool),
+ "unpair_on_close": Option(True, type=valid_bool),
+
+ "max_clients": Option(1, type=valid_int_f1),
+ "socket_timeout": Option(5.0, type=valid_float_f01),
+ "select_timeout": Option(1.0, type=valid_float_f01),
+ }
+
+ def sysprep(self) -> None:
+ get_logger(0).info("Starting HID daemon ...")
+ self.__proc = multiprocessing.Process(target=self.__server_worker, daemon=True)
+ self.__proc.start()
+
+ async def get_state(self) -> Dict:
+ state = await self.__server.get_state()
+ return {
+ "online": state["online"],
+ "keyboard": {
+ "online": state["online"],
+ "leds": {
+ "caps": state["caps"],
+ "scroll": state["scroll"],
+ "num": state["num"],
+ },
+ },
+ "mouse": {
+ "online": state["online"],
+ "absolute": False,
+ },
+ }
+
+ async def poll_state(self) -> AsyncGenerator[Dict, None]:
+ prev_state: Dict = {}
+ while True:
+ state = await self.get_state()
+ if state != prev_state:
+ yield state
+ prev_state = state
+ await self.__notifier.wait()
+
+ async def reset(self) -> None:
+ self.clear_events()
+ self.__server.queue_event(ResetEvent())
+
+ @aiotools.atomic
+ async def cleanup(self) -> None:
+ if self.__proc is not None:
+ if self.__proc.is_alive():
+ get_logger(0).info("Stopping HID daemon ...")
+ self.__stop_event.set()
+ if self.__proc.exitcode is not None:
+ self.__proc.join()
+
+ # =====
+
+ def send_key_events(self, keys: Iterable[Tuple[str, bool]]) -> None:
+ for (key, state) in keys:
+ self.__server.queue_event(make_keyboard_event(key, state))
+
+ def send_mouse_button_event(self, button: str, state: bool) -> None:
+ self.__server.queue_event(MouseButtonEvent(button, state))
+
+ def send_mouse_move_event(self, to_x: int, to_y: int) -> None:
+ _ = to_x # No absolute events
+ _ = to_y
+
+ def send_mouse_relative_event(self, delta_x: int, delta_y: int) -> None:
+ self.__server.queue_event(MouseRelativeEvent(delta_x, delta_y))
+
+ def send_mouse_wheel_event(self, delta_x: int, delta_y: int) -> None:
+ self.__server.queue_event(MouseWheelEvent(delta_x, delta_y))
+
+ def clear_events(self) -> None:
+ self.__server.clear_events()
+
+ # =====
+
+ def __server_worker(self) -> None: # pylint: disable=too-many-branches
+ logger = get_logger(0)
+
+ logger.info("Started HID pid=%d", os.getpid())
+ aioproc.ignore_sigint()
+ aioproc.rename_process("hid")
+
+ while not self.__stop_event.is_set():
+ try:
+ self.__server.run()
+ except Exception:
+ logger.exception("Unexpected HID error")
+ time.sleep(5)
diff --git a/kvmd/plugins/hid/bt/bluez.py b/kvmd/plugins/hid/bt/bluez.py
new file mode 100644
index 00000000..48639f69
--- /dev/null
+++ b/kvmd/plugins/hid/bt/bluez.py
@@ -0,0 +1,110 @@
+# ========================================================================== #
+# #
+# KVMD - The main Pi-KVM daemon. #
+# #
+# Copyright (C) 2018 Maxim Devaev <[email protected]> #
+# #
+# 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 types
+
+from typing import Type
+from typing import Optional
+from typing import Any
+
+import dbus
+import dbus.proxies
+
+
+# =====
+HID_CTL_PORT = 17
+HID_INT_PORT = 19
+
+
+# =====
+class BluezIface:
+ # https://git.kernel.org/pub/scm/bluetooth/bluez.git/tree/doc/profile-api.txt
+ # https://git.kernel.org/pub/scm/bluetooth/bluez.git/tree/doc/adapter-api.txt
+ # qdbus --system org.bluez /org/bluez/hci0 org.bluez.Adapter1.Address
+
+ def __init__(
+ self,
+ iface: str,
+ alias: str,
+ sdp_record: str,
+ pairing_required: bool,
+ auth_required: bool,
+ ) -> None:
+
+ self.__iface = iface
+ self.__alias = alias
+ self.__sdp_record = sdp_record
+ self.__pairing_required = pairing_required
+ self.__auth_required = auth_required
+
+ self.__bus: Optional[dbus.SystemBus] = None
+
+ def get_address(self) -> str:
+ return self.__get_prop("Address")
+
+ def configure(self) -> None:
+ self.__set_prop("Alias", self.__alias)
+ manager = dbus.Interface(self.__get_object("/org/bluez"), "org.bluez.ProfileManager1")
+ manager.RegisterProfile(f"/org/bluez/{self.__iface}", "00001124-0000-1000-8000-00805F9B34FB", {
+ "ServiceRecord": self.__sdp_record,
+ "Role": "server",
+ "RequireAuthentication": self.__pairing_required,
+ "RequireAuthorization": self.__auth_required,
+ })
+ self.__set_prop("Powered", True)
+
+ def set_public(self, public: bool) -> None:
+ self.__set_prop("Pairable", public)
+ self.__set_prop("Discoverable", public)
+
+ def unpair(self, addr: str) -> None:
+ adapter = dbus.Interface(self.__get_object(f"/org/bluez/{self.__iface}"), "org.bluez.Adapter1")
+ adapter.RemoveDevice(f"/org/bluez/hci0/dev_{addr.upper().replace(':', '_')}")
+
+ def __get_prop(self, key: str) -> Any:
+ return self.__get_props().Get("org.bluez.Adapter1", key)
+
+ def __set_prop(self, key: str, value: Any) -> None:
+ self.__get_props().Set("org.bluez.Adapter1", key, value)
+
+ def __get_props(self) -> dbus.Interface:
+ return dbus.Interface(self.__get_object(f"/org/bluez/{self.__iface}"), "org.freedesktop.DBus.Properties")
+
+ def __get_object(self, path: str) -> dbus.proxies.ProxyObject:
+ assert self.__bus is not None
+ return self.__bus.get_object("org.bluez", path)
+
+ def __enter__(self) -> "BluezIface":
+ assert self.__bus is None
+ self.__bus = dbus.SystemBus()
+ return self
+
+ def __exit__(
+ self,
+ _exc_type: Type[BaseException],
+ _exc: BaseException,
+ _tb: types.TracebackType,
+ ) -> None:
+
+ assert self.__bus is not None
+ self.__bus.close()
+ self.__bus = None
diff --git a/kvmd/plugins/hid/bt/sdp.py b/kvmd/plugins/hid/bt/sdp.py
new file mode 100644
index 00000000..acc00dd1
--- /dev/null
+++ b/kvmd/plugins/hid/bt/sdp.py
@@ -0,0 +1,152 @@
+# ========================================================================== #
+# #
+# KVMD - The main Pi-KVM daemon. #
+# #
+# Copyright (C) 2018 Maxim Devaev <[email protected]> #
+# #
+# 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 xml.sax.saxutils import escape
+
+from ....apps.otg.hid.keyboard import make_keyboard_hid
+from ....apps.otg.hid.mouse import make_mouse_hid
+
+
+# =====
+def make_sdp_record(manufacturer: str, product: str, description: str) -> str:
+ manufacturer = escape(manufacturer)
+ product = escape(product)
+ description = escape(description)
+
+ keyboard_descriptor = make_keyboard_hid(0x01).report_descriptor.hex().upper()
+ mouse_descriptor = make_mouse_hid(False, False, 0x02).report_descriptor.hex().upper()
+
+ return f"""
+ <?xml version="1.0" encoding="UTF-8" ?>
+ <record>
+ <attribute id="0x0001">
+ <sequence>
+ <uuid value="0x1124" />
+ </sequence>
+ </attribute>
+ <attribute id="0x0004">
+ <sequence>
+ <sequence>
+ <uuid value="0x0100" />
+ <uint16 value="0x0011" />
+ </sequence>
+ <sequence>
+ <uuid value="0x0011" />
+ </sequence>
+ </sequence>
+ </attribute>
+ <attribute id="0x0005">
+ <sequence>
+ <uuid value="0x1002" />
+ </sequence>
+ </attribute>
+ <attribute id="0x0006">
+ <sequence>
+ <uint16 value="0x656E" />
+ <uint16 value="0x006A" />
+ <uint16 value="0x0100" />
+ </sequence>
+ </attribute>
+ <attribute id="0x0009">
+ <sequence>
+ <sequence>
+ <uuid value="0x1124" />
+ <uint16 value="0x0100" />
+ </sequence>
+ </sequence>
+ </attribute>
+ <attribute id="0x000D">
+ <sequence>
+ <sequence>
+ <sequence>
+ <uuid value="0x0100" />
+ <uint16 value="0x0013" />
+ </sequence>
+ <sequence>
+ <uuid value="0x0011" />
+ </sequence>
+ </sequence>
+ </sequence>
+ </attribute>
+ <attribute id="0x0100">
+ <text value="{product}" />
+ </attribute>
+ <attribute id="0x0101">
+ <text value="{description}" />
+ </attribute>
+ <attribute id="0x0102">
+ <text value="{manufacturer}" />
+ </attribute>
+ <attribute id="0x0200">
+ <uint16 value="0x0100" />
+ </attribute>
+ <attribute id="0x0201">
+ <uint16 value="0x0111" />
+ </attribute>
+ <attribute id="0x0202">
+ <uint8 value="0xC0" />
+ </attribute>
+ <attribute id="0x0203">
+ <uint8 value="0x00" />
+ </attribute>
+ <attribute id="0x0204">
+ <boolean value="false" />
+ </attribute>
+ <attribute id="0x0205">
+ <boolean value="false" />
+ </attribute>
+ <attribute id="0x0206">
+ <sequence>
+ <sequence>
+ <uint8 value="0x22" />
+ <text encoding="hex" value="{keyboard_descriptor}{mouse_descriptor}" />
+ </sequence>
+ </sequence>
+ </attribute>
+ <attribute id="0x0207">
+ <sequence>
+ <sequence>
+ <uint16 value="0x0409" />
+ <uint16 value="0x0100" />
+ </sequence>
+ </sequence>
+ </attribute>
+ <attribute id="0x020B">
+ <uint16 value="0x0100" />
+ </attribute>
+ <attribute id="0x020C">
+ <uint16 value="0x0C80" />
+ </attribute>
+ <attribute id="0x020D">
+ <boolean value="false" />
+ </attribute>
+ <attribute id="0x020E">
+ <boolean value="false" />
+ </attribute>
+ <attribute id="0x020F">
+ <uint16 value="0x0640" />
+ </attribute>
+ <attribute id="0x0210">
+ <uint16 value="0x0320" />
+ </attribute>
+ </record>
+ """
diff --git a/kvmd/plugins/hid/bt/server.py b/kvmd/plugins/hid/bt/server.py
new file mode 100644
index 00000000..cd840f45
--- /dev/null
+++ b/kvmd/plugins/hid/bt/server.py
@@ -0,0 +1,390 @@
+# ========================================================================== #
+# #
+# KVMD - The main Pi-KVM daemon. #
+# #
+# Copyright (C) 2018 Maxim Devaev <[email protected]> #
+# #
+# 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 socket
+import select
+import multiprocessing
+import multiprocessing.synchronize
+import dataclasses
+import contextlib
+import queue
+
+from typing import Literal
+from typing import List
+from typing import Dict
+from typing import Set
+from typing import Generator
+from typing import Optional
+
+from ....logging import get_logger
+
+from .... import tools
+from .... import aiomulti
+
+from ....keyboard.mappings import OtgKey
+
+from ..otg.events import BaseEvent
+from ..otg.events import ClearEvent
+from ..otg.events import ResetEvent
+
+from ..otg.events import get_led_caps
+from ..otg.events import get_led_scroll
+from ..otg.events import get_led_num
+
+from ..otg.events import MouseButtonEvent
+from ..otg.events import MouseRelativeEvent
+from ..otg.events import MouseWheelEvent
+from ..otg.events import make_mouse_report
+
+from ..otg.events import KeyEvent
+from ..otg.events import ModifierEvent
+from ..otg.events import make_keyboard_report
+
+from .bluez import HID_CTL_PORT
+from .bluez import HID_INT_PORT
+from .bluez import BluezIface
+
+
+# =====
+_RoleT = Literal["CTL", "INT"]
+_SockAttrT = Literal["ctl_sock", "int_sock"]
+
+
+class _BtClient:
+ addr: str
+ ctl_sock: Optional[socket.socket] = None
+ int_sock: Optional[socket.socket] = None
+
+
+# =====
+class BtServer: # pylint: disable=too-many-instance-attributes
+ def __init__(
+ self,
+ iface: BluezIface,
+
+ control_public: bool,
+ unpair_on_close: bool,
+
+ max_clients: int,
+ socket_timeout: float,
+ select_timeout: float,
+
+ notifier: aiomulti.AioProcessNotifier,
+ stop_event: multiprocessing.synchronize.Event,
+ ) -> None:
+
+ self.__iface = iface
+
+ self.__control_public = control_public
+ self.__unpair_on_close = unpair_on_close
+
+ self.__max_clients = max_clients
+ self.__socket_timeout = socket_timeout
+ self.__select_timeout = select_timeout
+
+ self.__stop_event = stop_event
+
+ self.__clients: Dict[str, _BtClient] = {}
+ self.__to_read: Set[socket.socket] = set()
+
+ self.__events_queue: "multiprocessing.Queue[BaseEvent]" = multiprocessing.Queue()
+
+ self.__state_flags = aiomulti.AioSharedFlags({
+ "online": False,
+ "caps": False,
+ "scroll": False,
+ "num": False,
+ }, notifier)
+ self.__modifiers: Set[OtgKey] = set()
+ self.__keys: List[Optional[OtgKey]] = [None] * 6
+ self.__mouse_buttons = 0
+
+ def run(self) -> None:
+ with self.__iface:
+ self.__iface.configure()
+ self.__set_public(True)
+ addr = self.__iface.get_address()
+ try:
+ with self.__listen("CTL", addr, HID_CTL_PORT) as ctl_sock:
+ with self.__listen("INT", addr, HID_INT_PORT) as int_sock:
+ self.__main_loop(ctl_sock, int_sock)
+ finally:
+ self.__close_all_clients(no_change_public=True)
+ self.__set_public(False)
+
+ async def get_state(self) -> Dict:
+ return (await self.__state_flags.get())
+
+ def queue_event(self, event: BaseEvent) -> None:
+ if not self.__stop_event.is_set():
+ self.__events_queue.put_nowait(event)
+
+ def clear_events(self) -> None:
+ # FIXME: Если очистка производится со стороны процесса хида, то возможна гонка между
+ # очисткой и добавлением события ClearEvent. Неприятно, но не смертельно.
+ # Починить блокировкой после перехода на асинхронные очереди.
+ tools.clear_queue(self.__events_queue)
+ self.queue_event(ClearEvent())
+
+ # =====
+
+ @contextlib.contextmanager
+ def __listen(self, role: _RoleT, addr: str, port: int) -> Generator[socket.socket, None, None]:
+ get_logger(0).info("Listening [%s]:%d for %s ...", addr, port, role)
+ with socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) as sock:
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ sock.settimeout(self.__socket_timeout)
+ sock.bind((addr, port))
+ sock.listen(5)
+ yield sock
+
+ def __main_loop( # pylint: disable=too-many-branches
+ self,
+ server_ctl_sock: socket.socket,
+ server_int_sock: socket.socket,
+ ) -> None:
+
+ qr = self.__events_queue._reader # type: ignore # pylint: disable=protected-access
+ self.__to_read = set([qr, server_ctl_sock, server_int_sock])
+ self.__clients = {}
+
+ while not self.__stop_event.is_set():
+ (ready_read, _, _) = select.select(self.__to_read, [], [], self.__select_timeout)
+
+ if server_ctl_sock in ready_read:
+ self.__accept_client("CTL", server_ctl_sock, "ctl_sock")
+ if server_int_sock in ready_read:
+ self.__accept_client("INT", server_int_sock, "int_sock")
+
+ for client in list(self.__clients.values()):
+ sock = client.ctl_sock
+ if sock in ready_read:
+ assert sock is not None
+ try:
+ data = sock.recv(1024)
+ if not data:
+ self.__close_client("CTL", client, "ctl_sock")
+ elif data == b"\x71":
+ sock.send(b"\x00")
+ except Exception as err:
+ get_logger(0).exception("CTL socket error on %s: %s: %s",
+ client.addr, type(err).__name__, err)
+ self.__close_client("CTL", client, "ctl_sock")
+ continue
+
+ sock = client.int_sock
+ if sock in ready_read:
+ assert sock is not None
+ try:
+ data = sock.recv(1024)
+ if not data:
+ self.__close_client("INT", client, "int_sock")
+ elif data[:2] == b"\xA2\x01":
+ self.__process_leds(data[2])
+ except Exception as err:
+ get_logger(0).exception("INT socket error on %s: %s: %s",
+ client.addr, type(err).__name__, err)
+ self.__close_client("INT", client, "ctl_sock")
+
+ if qr in ready_read:
+ self.__process_events()
+
+ # =====
+
+ def __process_leds(self, leds: int) -> None:
+ self.__state_flags.update(
+ caps=get_led_caps(leds),
+ scroll=get_led_scroll(leds),
+ num=get_led_num(leds),
+ )
+
+ def __process_events(self) -> None:
+ for _ in range(self.__events_queue.qsize()):
+ try:
+ event = self.__events_queue.get_nowait()
+ except queue.Empty:
+ break
+ else:
+ if isinstance(event, ResetEvent):
+ self.__close_all_clients()
+ return
+
+ elif isinstance(event, ClearEvent):
+ self.__clear_modifiers()
+ self.__clear_keys()
+ self.__mouse_buttons = 0
+ self.__send_keyboard_state()
+ self.__send_mouse_state(0, 0, 0)
+
+ elif isinstance(event, ModifierEvent):
+ if event.modifier in self.__modifiers: # Ранее нажатый модификатор отжимаем
+ self.__modifiers.remove(event.modifier)
+ self.__send_keyboard_state()
+ if event.state: # Нажимаем если нужно
+ self.__modifiers.add(event.modifier)
+ self.__send_keyboard_state()
+
+ elif isinstance(event, KeyEvent):
+ if event.key in self.__keys: # Ранее нажатую клавишу отжимаем
+ self.__keys[self.__keys.index(event.key)] = None
+ self.__send_keyboard_state()
+ elif event.state and None not in self.__keys: # Если слоты полны - отжимаем всё
+ self.__clear_keys()
+ self.__send_keyboard_state()
+ if event.state: # Нажимаем если нужно
+ self.__keys[self.__keys.index(None)] = event.key
+ self.__send_keyboard_state()
+
+ elif isinstance(event, MouseButtonEvent):
+ if event.code & self.__mouse_buttons: # Ранее нажатую кнопку отжимаем
+ self.__mouse_buttons &= ~event.code
+ self.__send_mouse_state(0, 0, 0)
+ if event.state: # Нажимаем если нужно
+ self.__mouse_buttons |= event.code
+ self.__send_mouse_state(0, 0, 0)
+
+ elif isinstance(event, MouseRelativeEvent):
+ self.__send_mouse_state(event.delta_x, event.delta_y, 0)
+
+ elif isinstance(event, MouseWheelEvent):
+ self.__send_mouse_state(0, 0, event.delta_y)
+
+ def __send_keyboard_state(self) -> None:
+ for client in list(self.__clients.values()):
+ if client.int_sock is not None:
+ report = make_keyboard_report(self.__modifiers, self.__keys)
+ self.__send_report(client, "keyboard", b"\xA1\x01" + report)
+
+ def __send_mouse_state(self, move_x: int, move_y: int, wheel_y: int) -> None:
+ for client in list(self.__clients.values()):
+ if client.int_sock is not None:
+ report = make_mouse_report(False, self.__mouse_buttons, move_x, move_y, None, wheel_y)
+ self.__send_report(client, "mouse", b"\xA1\x02" + report)
+
+ def __send_report(self, client: _BtClient, name: str, report: bytes) -> None:
+ assert client.int_sock is not None
+ try:
+ client.int_sock.send(report)
+ except Exception as err:
+ get_logger(0).info("Can't send %s report to %s: %s: %s",
+ name, client.addr, type(err).__name__, err)
+ self.__close_client_pair(client)
+
+ def __clear_modifiers(self) -> None:
+ self.__modifiers.clear()
+
+ def __clear_keys(self) -> None:
+ self.__keys = [None] * 6
+
+ def __clear_state(self) -> None:
+ self.__state_flags.update(
+ online=False,
+ caps=False,
+ scroll=False,
+ num=False,
+ )
+ self.__clear_modifiers()
+ self.__clear_keys()
+ self.__mouse_buttons = 0
+
+ # =====
+
+ def __accept_client(self, role: _RoleT, server_sock: socket.socket, sock_attr: _SockAttrT) -> None:
+ try:
+ (sock, peer) = server_sock.accept()
+ sock.setblocking(True)
+ except Exception:
+ get_logger(0).exception("Can't accept %s client", role)
+ else:
+ if peer[0] not in self.__clients:
+ if len(self.__clients) >= self.__max_clients:
+ self.__close_sock(sock)
+ get_logger(0).info("Refused %s client: %s: max clients reached", role, peer[0])
+ return
+ self.__clients[peer[0]] = _BtClient(peer[0])
+ client = self.__clients[peer[0]]
+
+ assert hasattr(client, sock_attr)
+ setattr(client, sock_attr, sock)
+ self.__to_read.add(sock)
+
+ get_logger(0).info("Accepted %s client: %s", role, peer[0])
+ self.__state_flags.update(online=True)
+
+ self.__set_public(len(self.__clients) < self.__max_clients)
+
+ def __close_client(self, role: _RoleT, client: _BtClient, sock_attr: _SockAttrT, no_change_public: bool=False) -> None:
+ sock = getattr(client, sock_attr)
+ if sock is not None:
+ self.__close_sock(sock)
+ setattr(client, sock_attr, None)
+ self.__to_read.remove(sock)
+
+ get_logger(0).info("Closed %s client %s", role, client.addr)
+
+ if client.ctl_sock is None and client.int_sock is None:
+ self.__clients.pop(client.addr)
+ if self.__unpair_on_close:
+ self.__unpair_client(client)
+
+ if len(self.__clients) == 0:
+ self.__clear_state()
+
+ if not no_change_public:
+ self.__set_public(len(self.__clients) < self.__max_clients)
+
+ def __close_client_pair(self, client: _BtClient, no_change_public: bool=False) -> None:
+ self.__close_client("CTL", client, "ctl_sock", no_change_public)
+ self.__close_client("INT", client, "int_sock", no_change_public)
+
+ def __close_all_clients(self, no_change_public: bool=False) -> None:
+ for client in list(self.__clients.values()):
+ self.__close_client_pair(client, no_change_public)
+ self.__clear_state()
+ if not no_change_public:
+ self.__set_public(True)
+
+ def __close_sock(self, sock: socket.socket) -> None:
+ try:
+ sock.close()
+ except Exception:
+ pass
+
+ # =====
+
+ def __set_public(self, public: bool) -> None:
+ logger = get_logger(0)
+ if self.__control_public:
+ logger.info("Publishing ..." if public else "Unpublishing ...")
+ try:
+ self.__iface.set_public(public)
+ except Exception as err:
+ logger.error("Can't change public mode: %s: %s", type(err).__name__, err)
+
+ def __unpair_client(self, client: _BtClient) -> None:
+ logger = get_logger(0)
+ logger.info("Unpairing %s ...", client.addr)
+ try:
+ self.__iface.unpair(client.addr)
+ except Exception as err:
+ logger.error("Can't unpair %s: %s: %s", client.addr, type(err).__name__, err)
diff --git a/setup.py b/setup.py
index a100bb63..ae96ca25 100755
--- a/setup.py
+++ b/setup.py
@@ -84,6 +84,7 @@ def main() -> None:
"kvmd.plugins.auth",
"kvmd.plugins.hid",
"kvmd.plugins.hid.otg",
+ "kvmd.plugins.hid.bt",
"kvmd.plugins.atx",
"kvmd.plugins.msd",
"kvmd.plugins.msd.otg",