├── .gitignore ├── LICENSE ├── README.md ├── keyboardswitcher.py ├── keymap.py └── sdp_record.xml /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bluetooth Keyboard and Mouse switcher 2 | 3 | This script is based on some of many variations of bluetooth keyboard emulation in Python. I tied to get it to a usability level 4 | at which I can use it to act as a K(V)M switch replacement. Some of the feaures introcuded: 5 | 6 | * Support multiple connected bluetooth devices and hotkeys for switching between them 7 | * Handle mouse input emultion 8 | * Reconnect to paired devices 9 | * Handle any number of input devices as the emulated events source (with hot-swapping) 10 | * Handle keyboard LEDs 11 | 12 | ## Installation 13 | 14 | Tested on Rasbperry Pi OS 10 on Rasperry Pi 3B+ and Zero W. 15 | 16 | ### Install required system packages 17 | ``` 18 | sudo apt install git python3-dbus python3-pyudev python3-evdev 19 | ``` 20 | ### Download this repository 21 | ``` 22 | git clone https://github.com/nutki/bt-keyboard-switcher 23 | ``` 24 | ### Disable bluetooth input device client 25 | As far as I can tell there is no way for the defult bluetooth daemon in Rasperry Pi OS to be input device client and server at the same 26 | time. This means the client service has to be turned off first to accept connetions. 27 | 28 | Rasperry Pi OS 10 uses `systemd` to start the bluetooth service and the only way to disable the input plugin is to use a command line 29 | option. This means modifying `/lib/systemd/system/bluetooth.service` line: 30 | ``` 31 | ExecStart=/usr/lib/bluetooth/bluetoothd 32 | ``` 33 | to: 34 | ``` 35 | ExecStart=/usr/lib/bluetooth/bluetoothd --noplugin=input 36 | ``` 37 | And for the change to take an immediate effect: 38 | ``` 39 | sudo systemctl daemon-reload 40 | sudo systemctl restart bluetooth 41 | ``` 42 | ### Disable mouse event throttling (optional) 43 | Raspberry Pi OS throttles the mouse event rate at the HID level. This is not compatible with some mice (including Microsoft Sculpt ergonomic mouse I own) which causes mouse pointer lag. It is safe to disable it if you don't use grphical interface as this script does 44 | its own mouse movement event throttling anyway. To do so modify `/boot/cmdline.txt` adding the `usbhid.mousepoll=0` option. 45 | 46 | # Usage 47 | * Start the script 48 | ``` 49 | cd bt-keyboard-switcher 50 | sudo ./keyboardswitcher.py 51 | ``` 52 | * Note this script will take over all input devices connected, so it is not possible to normally stop it if you run it from the local console. To 53 | switch to local input passthrouh press `LCtrl + F12`. 54 | * Press `LCtrl + Esc` (on a keyboad physically connected to Raspberry Pi) this will make Raspberry Pi discoverable as "Pi Keyboard/Mouse". 55 | * Pair any BT clients (tested with iOS, Android, Windows 10, and Ubuntu Linux) 56 | * To switch between connected devices you can press `LCtrl + F1`, `LCtrl + F2`, etc. This will also trigger connect attempt if 57 | a previously paired device got disconnected. 58 | * Paired device information is stored in the `keyboardswitecher.ini` file, which can be edited to reassign the device order or remove a paired device. 59 | -------------------------------------------------------------------------------- /keyboardswitcher.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | import sys 5 | import dbus 6 | import time 7 | import evdev 8 | from evdev import ecodes 9 | import keymap 10 | from select import select 11 | import logging 12 | from logging import debug, info, warning, error 13 | import pyudev 14 | import re 15 | import functools 16 | import errno 17 | import socket 18 | from configparser import ConfigParser 19 | 20 | logging.basicConfig(level=logging.DEBUG) 21 | 22 | class Config: 23 | config_file = 'keyboardswitcher.ini' 24 | config = ConfigParser() 25 | config.read(config_file) 26 | @staticmethod 27 | def write(): 28 | with open(Config.config_file, 'w') as fh: 29 | Config.config.write(fh) 30 | @staticmethod 31 | def set_dev_config(addr, index): 32 | if not addr in Config.config: 33 | Config.config.add_section(addr) 34 | section = Config.config[addr] 35 | Config.config.set(addr, 'Index', str(index + 1)) 36 | if not 'MouseDelayMs' in section: 37 | Config.config.set(addr, 'MouseDelayMs', str(20)) 38 | Config.write() 39 | @staticmethod 40 | def get_dev_config(addr): 41 | if not addr in Config.config: 42 | return { 43 | 'index': None, 44 | 'mouse_delay': 20, 45 | 'mouse_speed': 1 46 | } 47 | section = Config.config[addr] 48 | return { 49 | 'mouse_delay': section.getint('MouseDelayMs') if 'MouseDelayMs' in section else 20, 50 | 'index': section.getint('Index') - 1 if 'Index' in section else None, 51 | 'mouse_speed': section.getfloat('MouseSpeed') if 'MouseSpeed' in section else 1 52 | } 53 | 54 | class BluetoothDevice: 55 | by_index = {} 56 | by_addr = {} 57 | current = 0 58 | connecting_sockets = [] 59 | @staticmethod 60 | def get_by_address(addr): 61 | if addr in BluetoothDevice.by_addr: 62 | return BluetoothDevice.by_addr.get(addr) 63 | return BluetoothDevice(addr) 64 | @staticmethod 65 | def get_by_index(index): 66 | return BluetoothDevice.by_index.get(index) 67 | @staticmethod 68 | def alloc_index(pref_index=None): 69 | if pref_index != None and pref_index not in BluetoothDevice.by_index: 70 | return pref_index 71 | index = 0 72 | while True: 73 | if index not in BluetoothDevice.by_index: 74 | return index 75 | index += 1 76 | @staticmethod 77 | def get_all(): 78 | return BluetoothDevice.by_index.values() 79 | @staticmethod 80 | def all_sockets(): 81 | d = BluetoothDevice.by_index.values() 82 | return [dev.isocket for dev in d if dev.isocket ] + [dev.csocket for dev in d if dev.csocket ] 83 | @staticmethod 84 | def print(): 85 | for i in BluetoothDevice.by_index: 86 | dev = BluetoothDevice.by_index[i] 87 | debug(dev) 88 | 89 | def __init__(self, addr): 90 | config = Config.get_dev_config(addr) 91 | self.mouse_delay = config['mouse_delay'] / 1000 92 | self.mouse_speed = config['mouse_speed'] 93 | self.state = "DISCONNECTED" 94 | self.csocket = None 95 | self.isocket = None 96 | self.addr = addr 97 | self.index = BluetoothDevice.alloc_index(config['index']) 98 | self.ledstate = 0 99 | Config.set_dev_config(addr, self.index) 100 | BluetoothDevice.by_addr[self.addr] = self 101 | BluetoothDevice.by_index[self.index] = self 102 | def __str__(self): 103 | return "%s%d: %s %s" % ('*' if BluetoothDevice.current == self.index else ' ', self.index, self.addr, self.state) 104 | def set_isocket(self, sock): 105 | self.isocket = sock 106 | self.state = "CONNECTED" if self.csocket else "CONNECTING" 107 | BluetoothDevice.print() 108 | def set_csocket(self, sock): 109 | self.csocket = sock 110 | self.state = "CONNECTED" if self.isocket else "CONNECTING" 111 | BluetoothDevice.print() 112 | def del_isocket(self): 113 | self.isocket = None 114 | self.state = "DISCONNECTING" if self.csocket else "DISCONNECTED" 115 | BluetoothDevice.print() 116 | def del_csocket(self): 117 | self.csocket = None 118 | self.state = "DISCONNECTING" if self.isocket else "DISCONNECTED" 119 | BluetoothDevice.print() 120 | def connect(self): 121 | debug("Connecting to %s", self.addr) 122 | BluetoothDevice.connect_nonblocking((self.addr, BluetoothDeviceManager.P_CTRL)) 123 | def send_input(self, ir): 124 | try: 125 | self.isocket.send(bytes(ir)) 126 | except OSError as err: 127 | error(err) 128 | self.del_isocket() 129 | @staticmethod 130 | def send_all(ir): 131 | for i in BluetoothDevice.by_index: 132 | BluetoothDevice.by_index[i].send_input(ir) 133 | @staticmethod 134 | def current_dev(): 135 | return BluetoothDevice.get_by_index(BluetoothDevice.current) 136 | @staticmethod 137 | def mouse_delay(): 138 | dev = BluetoothDevice.current_dev() 139 | return dev.mouse_delay if dev else 0 140 | @staticmethod 141 | def mouse_speed(): 142 | dev = BluetoothDevice.current_dev() 143 | return dev.mouse_speed if dev else 1 144 | @staticmethod 145 | def send_current(ir): 146 | dev = BluetoothDevice.current_dev() 147 | if dev and dev.isocket: 148 | dev.send_input(ir) 149 | @staticmethod 150 | def set_current(index): 151 | debug("Setting current to %d", index) 152 | dev = BluetoothDevice.current_dev() 153 | if index != BluetoothDevice.current: 154 | if BluetoothDevice.current == -1: 155 | InputDevice.grab(True) 156 | if index == -1: 157 | InputDevice.grab(False) 158 | if dev and dev.isocket: 159 | dev.send_input([0xA1, 1, 0, 0, 0, 0, 0, 0, 0, 0]) 160 | dev.send_input([0xA2, 2, 0, 0, 0, 0]) 161 | BluetoothDevice.current = index 162 | dev = BluetoothDevice.current_dev() 163 | InputDevice.set_leds_all(dev.ledstate if dev != None else 0) 164 | if dev and dev.state == "DISCONNECTED": 165 | dev.connect() 166 | @staticmethod 167 | def connect_nonblocking(addr): 168 | sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) 169 | sock.setblocking(0) 170 | try: 171 | sock.connect(addr) 172 | except OSError as err: 173 | if err.errno != errno.EINPROGRESS: 174 | error(err) 175 | return 176 | BluetoothDevice.connecting_sockets.append(sock) 177 | 178 | @staticmethod 179 | def connect_all(): 180 | debug("Setting pairable") 181 | os.system("hciconfig hci0 piscan") 182 | 183 | class BluetoothDeviceManager: 184 | P_CTRL = 17 185 | P_INTR = 19 186 | 187 | def __init__(self): 188 | os.system("hciconfig hci0 class 0x0025C0") 189 | os.system("hciconfig hci0 name Pi\ Keyboard/Mouse") 190 | 191 | self.bus = dbus.SystemBus() 192 | self.manager = dbus.Interface(self.bus.get_object("org.bluez", "/org/bluez"), 193 | "org.bluez.ProfileManager1") 194 | fh = open(sys.path[0] + "/sdp_record.xml", "r") 195 | self.service_record = fh.read() 196 | fh.close() 197 | opts = { "AutoConnect": True, "ServiceRecord": self.service_record } 198 | self.manager.RegisterProfile("/org/bluez/hci0", "00001124-0000-1000-8000-00805f9b34fb", opts) 199 | 200 | self.scontrol = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) #BluetoothSocket(L2CAP) 201 | self.sinterrupt = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP) #BluetoothSocket(L2CAP) 202 | self.scontrol.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 203 | self.sinterrupt.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 204 | self.scontrol.bind((socket.BDADDR_ANY, BluetoothDeviceManager.P_CTRL)) 205 | self.sinterrupt.bind((socket.BDADDR_ANY, BluetoothDeviceManager.P_INTR)) 206 | self.scontrol.listen(5) 207 | self.sinterrupt.listen(5) 208 | 209 | hotkeys = { 210 | (keymap.keymap[ecodes.KEY_ESC] + 256 * keymap.modkeymap[ecodes.KEY_LEFTCTRL]): lambda: BluetoothDevice.connect_all(), 211 | (keymap.keymap[ecodes.KEY_F1] + 256 * keymap.modkeymap[ecodes.KEY_LEFTCTRL]): lambda: BluetoothDevice.set_current(0), 212 | (keymap.keymap[ecodes.KEY_F2] + 256 * keymap.modkeymap[ecodes.KEY_LEFTCTRL]): lambda: BluetoothDevice.set_current(1), 213 | (keymap.keymap[ecodes.KEY_F3] + 256 * keymap.modkeymap[ecodes.KEY_LEFTCTRL]): lambda: BluetoothDevice.set_current(2), 214 | (keymap.keymap[ecodes.KEY_F4] + 256 * keymap.modkeymap[ecodes.KEY_LEFTCTRL]): lambda: BluetoothDevice.set_current(3), 215 | (keymap.keymap[ecodes.KEY_F5] + 256 * keymap.modkeymap[ecodes.KEY_LEFTCTRL]): lambda: BluetoothDevice.set_current(4), 216 | (keymap.keymap[ecodes.KEY_F6] + 256 * keymap.modkeymap[ecodes.KEY_LEFTCTRL]): lambda: BluetoothDevice.set_current(5), 217 | (keymap.keymap[ecodes.KEY_F7] + 256 * keymap.modkeymap[ecodes.KEY_LEFTCTRL]): lambda: BluetoothDevice.set_current(6), 218 | (keymap.keymap[ecodes.KEY_F8] + 256 * keymap.modkeymap[ecodes.KEY_LEFTCTRL]): lambda: BluetoothDevice.set_current(7), 219 | (keymap.keymap[ecodes.KEY_F9] + 256 * keymap.modkeymap[ecodes.KEY_LEFTCTRL]): lambda: BluetoothDevice.set_current(8), 220 | (keymap.keymap[ecodes.KEY_F10] + 256 * keymap.modkeymap[ecodes.KEY_LEFTCTRL]): lambda: BluetoothDevice.set_current(9), 221 | (keymap.keymap[ecodes.KEY_F11] + 256 * keymap.modkeymap[ecodes.KEY_LEFTCTRL]): lambda: BluetoothDevice.set_current(10), 222 | (keymap.keymap[ecodes.KEY_F12] + 256 * keymap.modkeymap[ecodes.KEY_LEFTCTRL]): lambda: BluetoothDevice.set_current(-1) 223 | } 224 | 225 | 226 | class InputDevice(): 227 | inputs = [] 228 | @staticmethod 229 | def init(): 230 | context = pyudev.Context() 231 | devs = context.list_devices(subsystem="input") 232 | InputDevice.monitor = pyudev.Monitor.from_netlink(context) 233 | InputDevice.monitor.filter_by(subsystem='input') 234 | InputDevice.monitor.start() 235 | for d in [*devs]: 236 | InputDevice.add_device(d) 237 | @staticmethod 238 | def add_device(dev): 239 | if dev.device_node == None or not re.match(".*/event\\d+", dev.device_node): 240 | return 241 | try: 242 | if "ID_INPUT_KEY" in dev.properties: 243 | InputDevice.inputs.append(KeyboardInput(dev.device_node)) 244 | if "ID_INPUT_MOUSE" in dev.properties: 245 | InputDevice.inputs.append(MouseInput(dev.device_node)) 246 | except OSError: 247 | error("Failed to connect to %s", dev.device_node) 248 | @staticmethod 249 | def remove_device(dev): 250 | if dev.device_node == None or not re.match(".*/event\\d+", dev.device_node): 251 | return 252 | InputDevice.inputs = list(filter(lambda i: i.device_node != dev.device_node, InputDevice.inputs)) 253 | info("Disconnected %s", dev) 254 | @staticmethod 255 | def set_leds_all(ledvalue): 256 | for dev in InputDevice.inputs: 257 | dev.set_leds(ledvalue) 258 | @staticmethod 259 | def grab(on): 260 | if on: 261 | debug("Grabbing all input devices") 262 | for dev in InputDevice.inputs: 263 | dev.device.grab() 264 | else: 265 | debug("Releasing all input devices") 266 | for dev in InputDevice.inputs: 267 | dev.device.ungrab() 268 | def __init__(self, device_node): 269 | self.device_node = device_node 270 | self.device = evdev.InputDevice(device_node) 271 | self.device.grab() 272 | info("Connected %s", self) 273 | def fileno(self): 274 | return self.device.fd 275 | def __str__(self): 276 | return "%s@%s (%s)" % (self.__class__.__name__, self.device_node, self.device.name) 277 | class KeyboardInput(InputDevice): 278 | def __init__(self, device_node): 279 | super().__init__(device_node) 280 | self.state = [ 0xA1, 1, 0, 0, 0, 0, 0, 0, 0, 0 ] 281 | self.set_leds(0) 282 | def set_leds(self, ledvalue): 283 | for i in range(5): 284 | self.device.set_led(i, 1 if ledvalue & (1< 1: 287 | return 288 | debug("Key event %s %d", ecodes.KEY[event.code], event.value) 289 | modkey_element = keymap.modkeymap.get(event.code) 290 | pressed_code = 0 291 | if modkey_element != None: 292 | if event.value == 1: 293 | self.state[2] |= modkey_element 294 | else: 295 | self.state[2] &= ~modkey_element 296 | else: 297 | hex_key = keymap.keymap.get(event.code) 298 | if hex_key == None: 299 | warning("Unknown evdev key code %d", event.code) 300 | return 301 | if event.value == 1 and sum(self.state[4:]) == 0: 302 | pressed_code = self.state[2] * 256 + hex_key 303 | for i in range (4, 10): 304 | if self.state[i] == hex_key and event.value == 0: 305 | self.state[i] = 0x00 306 | elif self.state[i] == 0x00 and event.value == 1: 307 | self.state[i] = hex_key 308 | break 309 | if pressed_code in hotkeys: 310 | hotkeys[pressed_code]() 311 | else: 312 | BluetoothDevice.send_current(self.state) 313 | class MouseInput(InputDevice): 314 | def __init__(self, device_node): 315 | super().__init__(device_node) 316 | self.state = [ 0xA1, 2, 0, 0, 0, 0 ] 317 | self.x = 0 318 | self.y = 0 319 | self.z = 0 320 | self.change = False 321 | self.last = 0 322 | def change_state(self, event): 323 | if event.type == ecodes.EV_SYN: 324 | current = time.monotonic() 325 | if current - self.last < BluetoothDevice.mouse_delay() and not self.change: 326 | return 327 | self.last = current 328 | speed = BluetoothDevice.mouse_speed() 329 | self.state[3] = min(127, max(-127, int(self.x * speed))) & 255 330 | self.state[4] = min(127, max(-127, int(self.y * speed))) & 255 331 | self.state[5] = min(127, max(-127, self.z)) & 255 332 | self.x = 0 333 | self.y = 0 334 | self.z = 0 335 | self.change = False 336 | BluetoothDevice.send_current(self.state) 337 | if event.type == ecodes.EV_KEY: 338 | debug("Key event %s %d", ecodes.BTN[event.code], event.value) 339 | self.change = True 340 | if event.code >= 272 and event.code <= 276 and event.value < 2: 341 | button_no = event.code - 272 342 | if event.value == 1: 343 | self.state[2] |= 1 << button_no 344 | else: 345 | self.state[2] &= ~(1 << button_no) 346 | if event.type == ecodes.EV_REL: 347 | if event.code == 0: 348 | self.x += event.value 349 | if event.code == 1: 350 | self.y += event.value 351 | if event.code == 8: 352 | self.z += event.value 353 | def set_leds(self, ledvalue): 354 | pass 355 | 356 | def event_loop(bt): 357 | while True: 358 | desctiptors = [*InputDevice.inputs, InputDevice.monitor, bt.scontrol, bt.sinterrupt, *BluetoothDevice.all_sockets()] 359 | wdescriptors = BluetoothDevice.connecting_sockets 360 | r, w, x = select(desctiptors, wdescriptors, []) 361 | for sock in w: 362 | try: 363 | err = sock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) 364 | if err: 365 | raise OSError(err, os.strerror(err)) 366 | addr = sock.getpeername() 367 | debug("Connected: %s", addr) 368 | dev = BluetoothDevice.get_by_address(addr[0]) 369 | if addr[1] == BluetoothDeviceManager.P_CTRL: 370 | dev.set_csocket(sock) 371 | BluetoothDevice.connect_nonblocking((addr[0], BluetoothDeviceManager.P_INTR)) 372 | if addr[1] == BluetoothDeviceManager.P_INTR: 373 | dev.set_isocket(sock) 374 | except OSError as err: 375 | warning("Connection error: %s", err) 376 | BluetoothDevice.connecting_sockets.remove(sock) 377 | if InputDevice.monitor in r: 378 | for d in iter(functools.partial(InputDevice.monitor.poll, 0), None): 379 | if d.action == 'add': 380 | InputDevice.add_device(d) 381 | if d.action == 'remove': 382 | InputDevice.remove_device(d) 383 | for i in InputDevice.inputs: 384 | if i in r: 385 | try: 386 | for event in i.device.read(): 387 | i.change_state(event) 388 | except OSError as err: 389 | if err.errno == errno.ENODEV: 390 | InputDevice.remove_device(i) 391 | warning(err) 392 | for dev in BluetoothDevice.get_all(): 393 | sock = dev.csocket 394 | if sock in r: 395 | debug("DAT @ %d", sock.fileno()) 396 | try: 397 | data = sock.recv(1024) 398 | if not len(data): 399 | dev.del_csocket() 400 | debug("DAT %s", data.hex()) 401 | if data == bytes([0x71]): 402 | debug("OK") 403 | sock.send(bytes([0])) 404 | except OSError as err: 405 | debug(err) 406 | dev.del_csocket() 407 | sock = dev.isocket 408 | if sock in r: 409 | try: 410 | data = sock.recv(1024) 411 | if not len(data): 412 | dev.del_isocket() 413 | debug("DATINT %s", data.hex()) 414 | if data[0:2] == bytes([0xa2,0x01]): 415 | dev.ledstate = data[2] 416 | InputDevice.set_leds_all(data[2]) 417 | except OSError as err: 418 | debug(err) 419 | dev.del_isocket() 420 | 421 | if bt.sinterrupt in r: 422 | newsock, addr = bt.sinterrupt.accept() 423 | newsock.setblocking(1) 424 | BluetoothDevice.get_by_address(addr[0]).set_isocket(newsock) 425 | info("INT %d %s", newsock.fileno(), addr) 426 | if bt.scontrol in r: 427 | newsock, addr = bt.scontrol.accept() 428 | newsock.setblocking(1) 429 | BluetoothDevice.get_by_address(addr[0]).set_csocket(newsock) 430 | info("CTL %d %s", newsock.fileno(), addr) 431 | 432 | if __name__ == "__main__": 433 | try: 434 | if not os.geteuid() == 0: 435 | sys.exit("Run as root") 436 | 437 | for addr in Config.config.sections(): 438 | BluetoothDevice(addr) 439 | bt = BluetoothDeviceManager() 440 | InputDevice.init() 441 | 442 | event_loop(bt) 443 | except KeyboardInterrupt: 444 | sys.exit() 445 | -------------------------------------------------------------------------------- /keymap.py: -------------------------------------------------------------------------------- 1 | from evdev import ecodes 2 | 3 | keymap = { 4 | ecodes.KEY_RESERVED: 0, 5 | ecodes.KEY_ESC: 41, 6 | ecodes.KEY_1: 30, 7 | ecodes.KEY_2: 31, 8 | ecodes.KEY_3: 32, 9 | ecodes.KEY_4: 33, 10 | ecodes.KEY_5: 34, 11 | ecodes.KEY_6: 35, 12 | ecodes.KEY_7: 36, 13 | ecodes.KEY_8: 37, 14 | ecodes.KEY_9: 38, 15 | ecodes.KEY_0: 39, 16 | ecodes.KEY_MINUS: 45, 17 | ecodes.KEY_EQUAL: 46, 18 | ecodes.KEY_BACKSPACE: 42, 19 | ecodes.KEY_TAB: 43, 20 | ecodes.KEY_Q: 20, 21 | ecodes.KEY_W: 26, 22 | ecodes.KEY_E: 8, 23 | ecodes.KEY_R: 21, 24 | ecodes.KEY_T: 23, 25 | ecodes.KEY_Y: 28, 26 | ecodes.KEY_U: 24, 27 | ecodes.KEY_I: 12, 28 | ecodes.KEY_O: 18, 29 | ecodes.KEY_P: 19, 30 | ecodes.KEY_LEFTBRACE: 47, 31 | ecodes.KEY_RIGHTBRACE: 48, 32 | ecodes.KEY_ENTER: 40, 33 | ecodes.KEY_LEFTCTRL: 224, 34 | ecodes.KEY_A: 4, 35 | ecodes.KEY_S: 22, 36 | ecodes.KEY_D: 7, 37 | ecodes.KEY_F: 9, 38 | ecodes.KEY_G: 10, 39 | ecodes.KEY_H: 11, 40 | ecodes.KEY_J: 13, 41 | ecodes.KEY_K: 14, 42 | ecodes.KEY_L: 15, 43 | ecodes.KEY_SEMICOLON: 51, 44 | ecodes.KEY_APOSTROPHE: 52, 45 | ecodes.KEY_GRAVE: 53, 46 | ecodes.KEY_LEFTSHIFT: 225, 47 | ecodes.KEY_BACKSLASH: 50, 48 | ecodes.KEY_Z: 29, 49 | ecodes.KEY_X: 27, 50 | ecodes.KEY_C: 6, 51 | ecodes.KEY_V: 25, 52 | ecodes.KEY_B: 5, 53 | ecodes.KEY_N: 17, 54 | ecodes.KEY_M: 16, 55 | ecodes.KEY_COMMA: 54, 56 | ecodes.KEY_DOT: 55, 57 | ecodes.KEY_SLASH: 56, 58 | ecodes.KEY_RIGHTSHIFT: 229, 59 | ecodes.KEY_KPASTERISK: 85, 60 | ecodes.KEY_LEFTALT: 226, 61 | ecodes.KEY_SPACE: 44, 62 | ecodes.KEY_CAPSLOCK: 57, 63 | ecodes.KEY_F1: 58, 64 | ecodes.KEY_F2: 59, 65 | ecodes.KEY_F3: 60, 66 | ecodes.KEY_F4: 61, 67 | ecodes.KEY_F5: 62, 68 | ecodes.KEY_F6: 63, 69 | ecodes.KEY_F7: 64, 70 | ecodes.KEY_F8: 65, 71 | ecodes.KEY_F9: 66, 72 | ecodes.KEY_F10: 67, 73 | ecodes.KEY_NUMLOCK: 83, 74 | ecodes.KEY_SCROLLLOCK: 71, 75 | ecodes.KEY_KP7: 95, 76 | ecodes.KEY_KP8: 96, 77 | ecodes.KEY_KP9: 97, 78 | ecodes.KEY_KPMINUS: 86, 79 | ecodes.KEY_KP4: 92, 80 | ecodes.KEY_KP5: 93, 81 | ecodes.KEY_KP6: 94, 82 | ecodes.KEY_KPPLUS: 87, 83 | ecodes.KEY_KP1: 89, 84 | ecodes.KEY_KP2: 90, 85 | ecodes.KEY_KP3: 91, 86 | ecodes.KEY_KP0: 98, 87 | ecodes.KEY_KPDOT: 99, 88 | ecodes.KEY_ZENKAKUHANKAKU: 148, 89 | ecodes.KEY_102ND: 100, 90 | ecodes.KEY_F11: 68, 91 | ecodes.KEY_F12: 69, 92 | ecodes.KEY_RO: 135, 93 | ecodes.KEY_KATAKANA: 146, 94 | ecodes.KEY_HIRAGANA: 147, 95 | ecodes.KEY_HENKAN: 138, 96 | ecodes.KEY_KATAKANAHIRAGANA: 136, 97 | ecodes.KEY_MUHENKAN: 139, 98 | ecodes.KEY_KPJPCOMMA: 140, 99 | ecodes.KEY_KPENTER: 88, 100 | ecodes.KEY_RIGHTCTRL: 228, 101 | ecodes.KEY_KPSLASH: 84, 102 | ecodes.KEY_SYSRQ: 70, 103 | ecodes.KEY_RIGHTALT: 230, 104 | ecodes.KEY_HOME: 74, 105 | ecodes.KEY_UP: 82, 106 | ecodes.KEY_PAGEUP: 75, 107 | ecodes.KEY_LEFT: 80, 108 | ecodes.KEY_RIGHT: 79, 109 | ecodes.KEY_END: 77, 110 | ecodes.KEY_DOWN: 81, 111 | ecodes.KEY_PAGEDOWN: 78, 112 | ecodes.KEY_INSERT: 73, 113 | ecodes.KEY_DELETE: 76, 114 | ecodes.KEY_MUTE: 239, 115 | ecodes.KEY_VOLUMEDOWN: 238, 116 | ecodes.KEY_VOLUMEUP: 237, 117 | ecodes.KEY_POWER: 102, 118 | ecodes.KEY_KPEQUAL: 103, 119 | ecodes.KEY_PAUSE: 72, 120 | ecodes.KEY_KPCOMMA: 133, 121 | ecodes.KEY_HANGEUL: 144, 122 | ecodes.KEY_HANJA: 145, 123 | ecodes.KEY_YEN: 137, 124 | ecodes.KEY_LEFTMETA: 227, 125 | ecodes.KEY_RIGHTMETA: 231, 126 | ecodes.KEY_COMPOSE: 101, 127 | ecodes.KEY_STOP: 243, 128 | ecodes.KEY_AGAIN: 121, 129 | ecodes.KEY_PROPS: 118, 130 | ecodes.KEY_UNDO: 122, 131 | ecodes.KEY_FRONT: 119, 132 | ecodes.KEY_COPY: 124, 133 | ecodes.KEY_OPEN: 116, 134 | ecodes.KEY_PASTE: 125, 135 | ecodes.KEY_FIND: 244, 136 | ecodes.KEY_CUT: 123, 137 | ecodes.KEY_HELP: 117, 138 | ecodes.KEY_CALC: 251, 139 | ecodes.KEY_SLEEP: 248, 140 | ecodes.KEY_WWW: 240, 141 | ecodes.KEY_COFFEE: 249, 142 | ecodes.KEY_BACK: 241, 143 | ecodes.KEY_FORWARD: 242, 144 | ecodes.KEY_EJECTCD: 236, 145 | ecodes.KEY_NEXTSONG: 235, 146 | ecodes.KEY_PLAYPAUSE: 232, 147 | ecodes.KEY_PREVIOUSSONG: 234, 148 | ecodes.KEY_STOPCD: 233, 149 | ecodes.KEY_REFRESH: 250, 150 | ecodes.KEY_EDIT: 247, 151 | ecodes.KEY_SCROLLUP: 245, 152 | ecodes.KEY_SCROLLDOWN: 246, 153 | ecodes.KEY_F13: 104, 154 | ecodes.KEY_F14: 105, 155 | ecodes.KEY_F15: 106, 156 | ecodes.KEY_F16: 107, 157 | ecodes.KEY_F17: 108, 158 | ecodes.KEY_F18: 109, 159 | ecodes.KEY_F19: 110, 160 | ecodes.KEY_F20: 111, 161 | ecodes.KEY_F21: 112, 162 | ecodes.KEY_F22: 113, 163 | ecodes.KEY_F23: 114, 164 | ecodes.KEY_F24: 115 165 | } 166 | 167 | modkeymap = { 168 | ecodes.KEY_LEFTCTRL: 0x01, 169 | ecodes.KEY_LEFTSHIFT: 0x02, 170 | ecodes.KEY_LEFTALT: 0x04, 171 | ecodes.KEY_LEFTMETA: 0x08, 172 | ecodes.KEY_RIGHTCTRL: 0x10, 173 | ecodes.KEY_RIGHTSHIFT: 0x20, 174 | ecodes.KEY_RIGHTALT: 0x40, 175 | ecodes.KEY_RIGHTMETA: 0x80, 176 | } 177 | -------------------------------------------------------------------------------- /sdp_record.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | --------------------------------------------------------------------------------