diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..1cdb30aa --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Debug client.py (write value)", + "type": "debugpy", + "request": "launch", + "program": "${workspaceRoot}/src/gatt_client/client.py", + "console": "integratedTerminal", + "args" : ["-a", "B8:27:EB:9C:F6:4C", "-c", "00000001-6907-4437-8539-9218a9d54e29", "Win"] + }, + { + "name": "client.py (help)", + "type": "debugpy", + "request": "launch", + "program": "${workspaceRoot}/src/gatt_client/client.py", + "console": "integratedTerminal", + "args" : ["--help"] + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index d1008e93..6d659174 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ - # Bluetooth to USB ![Bluetooth to USB Overview](https://raw.githubusercontent.com/quaxalber/bluetooth_2_usb/main/assets/overview.png) -Convert a Raspberry Pi into a HID relay that translates Bluetooth keyboard and mouse input to USB. Minimal configuration. Zero hassle. +Convert a Raspberry Pi into a HID relay that translates Bluetooth keyboard and mouse input to USB. Additionally, supports sending keystrokes remotely from your PC or phone via Bluetooth LE service. Minimal configuration. Zero hassle. The issue with Bluetooth devices is that you usually can't use them to: - wake up sleeping devices, @@ -22,27 +21,36 @@ Linux's gadget mode allows a Raspberry Pi to act as USB HID (Human Interface Dev - [3. Installation](#3-installation) - [3.1. Prerequisites](#31-prerequisites) - [3.2. Setup](#32-setup) + - [3.3. Known issues](#33-known-issues) - [4. Usage](#4-usage) - [4.1. Connection to target device / host](#41-connection-to-target-device--host) - [4.1.1. Raspberry Pi 4 Model B](#411-raspberry-pi-4-model-b) - [4.1.2. Raspberry Pi Zero (2) W(H)](#412-raspberry-pi-zero-2-wh) - [4.2. Command-line arguments](#42-command-line-arguments) - - [4.3. Consuming the API from your Python code](#43-consuming-the-api-from-your-python-code) + - [4.3 Bluetooth to USB GATT](#43-bluetooth-to-usb-gatt) + - [4.3.1 Cross-platform python client sample](#431-cross-platform-python-client-sample) + - [4.3.2 Windows clients](#432-windows-clients) + - [4.3.3 Android clients](#433-android-clients) + - [4.4. Consuming the API from your Python code](#44-consuming-the-api-from-your-python-code) - [5. Updating](#5-updating) - [6. Uninstallation](#6-uninstallation) - [7. Troubleshooting](#7-troubleshooting) - [7.1. The Pi keeps rebooting or crashes randomly](#71-the-pi-keeps-rebooting-or-crashes-randomly) - [7.2. The installation was successful, but I don't see any output on the target device](#72-the-installation-was-successful-but-i-dont-see-any-output-on-the-target-device) - [7.3. In bluetoothctl, my device is constantly switching on/off](#73-in-bluetoothctl-my-device-is-constantly-switching-onoff) - - [7.4. I have a different issue](#74-i-have-a-different-issue) - - [7.5. Everything is working, but can it help me with Bitcoin mining?](#75-everything-is-working-but-can-it-help-me-with-bitcoin-mining) + - [7.4. There are occansional Bluetooth disconnects on Pi Zero 2](#74-there-are-occansional-bluetooth-disconnects-on-pi-zero-2) + - [7.5. There are occansional Wi-Fi disconnects on Pi Zero 2](#75-there-are-occansional-wi-fi-disconnects-on-pi-zero-2) + - [7.6. I have a different issue](#76-i-have-a-different-issue) + - [7.7. Everything is working, but can it help me with Bitcoin mining?](#77-everything-is-working-but-can-it-help-me-with-bitcoin-mining) - [8. Bonus points](#8-bonus-points) - [9. Contributing](#9-contributing) - [10. License](#10-license) - [11. Acknowledgments](#11-acknowledgments) + ## 1. Features +**HID relay:** - Simple installation and highly automated setup - Supports multiple input devices (currently keyboard and mouse - more than one of each kind simultaneously) - Supports [146 multimedia keys](https://github.com/quaxalber/bluetooth_2_usb/blob/8b1c5f8097bbdedfe4cef46e07686a1059ea2979/lib/evdev_adapter.py#L142) (e.g., mute, volume up/down, launch browser, etc.) @@ -53,6 +61,16 @@ Linux's gadget mode allows a Raspberry Pi to act as USB HID (Human Interface Dev - Reliable concurrency using state-of-the-art [TaskGroups](https://docs.python.org/3/library/asyncio-task.html#task-groups) - Clean and actively maintained code base +**Bluetooth LE service:** +- Supports sending keystrokes (a series of shortcuts) remotely from your PC or phone. See [4.3 Bluetooth to USB GATT](#43-bluetooth-to-usb-gatt) for usage guidelines. +- Accepts (almost) any format. Both Linux keycode names (see [Adaftuit keycodes](https://docs.circuitpython.org/projects/hid/en/latest/_modules/adafruit_hid/keycode.html)) and [Windows ones](https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes) are supported. + Example of input: `Win-R n,o,t,e,p,a,d Enter` +- Works as a Bluetooth GATT Service (is compatiblle with existing BLE GATT client applications) +- Requires client device to be paired (may be disabled by `--accept-non-trusted` command-line argument; if enabled, only whitelisted devices may send the keystrokes). +- Returns error if invalid keystroke is sent (may be disabled by `--partial-parse-ble-command` command-line argument) +- May be disabled using `--no-ble-relay` option +- Tested on Raspberry Pi 4, Raspberry Pi Zero W and Raspberry Pi Zero 2 W + ## 2. Requirements - A Raspberry Pi with Bluetooth and [USB OTG support](https://en.wikipedia.org/wiki/USB_On-The-Go) required for [USB gadgets](https://www.kernel.org/doc/html/latest/driver-api/usb/gadget.html) in so-called device mode. Supported models include: @@ -75,22 +93,22 @@ Follow these steps to install and configure the project: ### 3.1. Prerequisites 1. Install Raspberry Pi OS on your Raspberry Pi (e.g., using [Pi Imager](https://youtu.be/ntaXWS8Lk34)) - + 2. Connect to a network via Ethernet cable or [Wi-Fi](https://www.raspberrypi.com/documentation/computers/configuration.html#configuring-networking). Make sure this network has Internet access. - + 3. (*optional, recommended*) Enable [SSH](https://www.raspberrypi.com/documentation/computers/remote-access.html#ssh), if you intend to access the Pi remotely. > [!NOTE] > These settings above may be configured [during imaging](https://www.raspberrypi.com/documentation/computers/getting-started.html#advanced-options) (recommended), [on first boot](https://www.raspberrypi.com/documentation/computers/getting-started.html#configuration-on-first-boot) or [afterwards](https://www.raspberrypi.com/documentation/computers/configuration.html). - + 4. Connect to the Pi and make sure `git` is installed: - + ```console sudo apt update && sudo apt upgrade -y && sudo apt install -y git ``` 5. Pair and trust any Bluetooth devices you wish to relay, either via GUI or via CLI: - + ```console bluetoothctl scan on @@ -109,26 +127,28 @@ Follow these steps to install and configure the project: ### 3.2. Setup -6. On the Pi, clone the repository to your home directory: - +1. On the Pi, clone the repository to your home directory: + ```console - cd ~ && git clone https://github.com/quaxalber/bluetooth_2_usb.git + # as it is for now the BLE servie feature is not complete and located at forked repo + # cd ~ && git clone https://github.com/quaxalber/bluetooth_2_usb.git + cd ~ && git clone https://github.com/ig-sinicyn/bluetooth_2_usb.git ``` -7. Run the installation script as root: - +2. Run the installation script as root: + ```console sudo ~/bluetooth_2_usb/scripts/install.sh ``` -8. Reboot: - +3. Reboot: + ```console sudo reboot - ``` + ``` + +4. Verify that the service is running: -9. Verify that the service is running: - ```console service bluetooth_2_usb status ``` @@ -150,6 +170,8 @@ Follow these steps to install and configure the project: Dec 13 10:33:00 pi0w systemd[1]: Started bluetooth_2_usb.service - Bluetooth to USB HID relay. Dec 13 10:33:06 pi0w bluetooth_2_usb[5869]: 23-12-13 10:33:06 [INFO] Launching Bluetooth 2 USB v0.8.0 Dec 13 10:33:06 pi0w bluetooth_2_usb[5869]: 23-12-13 10:33:06 [INFO] Discovering input devices... + Dec 13 10:33:08 pi0w bluetooth_2_usb[5869]: 23-12-13 10:33:08 [INFO] Activated BLE TO HID relay. Pairing required: True. Allows invalid input: False + Dec 13 10:33:08 pi0w bluetooth_2_usb[5869]: 23-12-13 10:33:08 [INFO] Use 00000000-6907-4437-8539-9218a9d54e29 service / 00000001-6907-4437-8539-9218a9d54e29 characteristic to send keystrokes. Dec 13 10:33:09 pi0w bluetooth_2_usb[5869]: 23-12-13 10:33:09 [INFO] Activated relay for device /dev/input/event2, name "AceRK Mouse", phys "0a:1b:2c:3d:4e:5f" Dec 13 10:33:09 pi0w bluetooth_2_usb[5869]: 23-12-13 10:33:09 [INFO] Activated relay for device /dev/input/event1, name "AceRK Keyboard", phys "0a:1b:2c:3d:4e:5f" Dec 13 10:33:09 pi0w bluetooth_2_usb[5869]: 23-12-13 10:33:09 [INFO] Activated relay for device /dev/input/event0, name "vc4-hdmi", phys "vc4-hdmi/input0" @@ -157,7 +179,23 @@ Follow these steps to install and configure the project: > [!NOTE] > Something seems off? Try yourself in [Troubleshooting](#7-troubleshooting)! - + +### 3.3. Known issues + +**No module named 'evdev' error** + +This error may occur on fresh Bookworm images. May be fixed with +```console +sudo apt install python3-evdev +``` + +**Python.h: No such file or directory error** + +This error may occur on fresh Bookworm images. May be fixed with +```console +sudo apt install libpython3.11-dev +``` + ## 4. Usage ### 4.1. Connection to target device / host @@ -194,6 +232,13 @@ options: --grab_devices, -g Grab the input devices, i.e., suppress any events on your relay device. Devices are not grabbed by default. --list_devices, -l List all available input devices and exit. + --no-input-relay Disable input relay mode (sends input keys to USB HID device) + Default: input relay enabled. + --no-ble-relay Disable BLE relay mode (BLE server that sends keystrokes to USB HID device) + Default: BLE relay enabled. + --accept-non-trusted UNSAFE! Accepts non-trusted BLE relay clients. + --partial-parse-ble-command + Enables partial parsing of BLE input (ignores unknown keystrokes). --log_to_file, -f Add a handler that logs to file, additionally to stdout. --log_path LOG_PATH, -p LOG_PATH The path of the log file @@ -204,7 +249,97 @@ options: --help, -h Show this help message and exit. ``` -### 4.3. Consuming the API from your Python code +### 4.3 Bluetooth to USB GATT + +This service allows you to send keystrokes remotely from your PC or phone. + +The things you need to know: +* The bluetooth address of the Pi device (may be obtained by `hcitool dev` command) +* Names of GATT service and characteristic to use. Currently these are not configurable and are equal to `00000000-6907-4437-8539-9218a9d54e29` and `00000001-6907-4437-8539-9218a9d54e29` +* You must pair your client device with Raspberry Pi (the pairing must be triggered on the client side). Some clients do support auto-pairing, others require you to do it manually. + +**Keystrokes format** + +TLDR example: `Win-1, F5, Alt-Tab` switches to the first app in the windows taskbar, presses F5 and switches back. + +Each keystroke contains one or more shortcuts. The shortcuts are separated by whitespace (` `), comma (`,`) or semicolon (`;`) symbols. + +Each shortcut represents a combination of keys that will be presseed simultaneously. The key names are separated by dash ('-') or plus (`+`) symbols. + +Key names are case sensitive. Both Linux keycode names (see [Adaftuit keycodes](https://docs.circuitpython.org/projects/hid/en/latest/_modules/adafruit_hid/keycode.html)) and [Windows ones](https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes) are supported. + +More examples: +``` +Ctrl-E,Ctrl-K # Two sequental shortcuts, Ctrl-E and Ctrl-K +Ctrl+Alt+P # A single shortcut, Ctrl-Alt-P +LAlt-M;RAlt-D # Two sequental shortcuts, Left Alt-M and Right Alt-D +Win-R n,o,t,e,p,a,d Enter # Starts a notepad app +``` + +#### 4.3.1 Cross-platform python client sample + +This basic sample is included [as part of the repo](https://github.com/ig-sinicyn/bluetooth_2_usb/blob/feature/ble-relay/src/gatt_client/client.py). +Assuming you have configured python venv, dependencies may be installed by running following command in the root of the repository (choose the one for your OS): +``` +# Windows: +.venv\Scripts\activate +py -m pip install -r .\requirements.client.txt +deactivate + +# Linux: +source .venv/bin/activate +py -m pip install -r .\requirements.client.txt +deactivate +``` + +**Usage:** +```console +>py 'src/gatt_client/client.py' +usage: client.py [-h] [--address ADDRESS] [--characteristic CHARACTERISTIC] value + +> py 'src/gatt_client/client.py' '-a' 'B8:27:EB:9C:F6:4C' '-c' '00000001-6907-4437-8539-9218a9d54e29' 'Alt-Tab' +Connected to B8:27:EB:9C:F6:4C: True +Writing value 'Alt-Tab' +Value 'Alt-Tab' written to characteristic '00000001-6907-4437-8539-9218a9d54e29' +``` + +> [!NOTE] +> Sometimes (very rarely) client fails with 'Device with address
was not found'. This error seems to be fixed by running client multiple times or by restarting the 'Bluetooth Support Service' service. + +> [!NOTE] +> GATT Client execution time may take up to several second on Windows. It is recommended to try other clients that may work much faster. + +#### 4.3.2 Windows clients + +These are existing windows 10 + clients such as [BleConsole](https://github.com/sensboston/BLEConsole) and [Bluetooth LE Lab](https://apps.microsoft.com/detail/9n6jd37gwzc8) but these are hard to use in automation. + +So, there is [BleTools](https://github.com/ig-sinicyn/BleTools) as set of fast (~300ms to run) and robust utilites for writing. Please check [the documentation](https://github.com/ig-sinicyn/BleTools/blob/master/README.md) for more details. +Example: +```console +> .\BleTools.Write.exe +Usage: BleTools.Write bluetooth-address service characteristic value + +Writes GATT service characteristic + +Arguments: + 0: bluetooth-address MAC address of the Bluetooth LE device (Required) + 1: service GATT service UUID (Required) + 2: characteristic GATT service characteristic UUID (Required) + 3: value The new characteristic value (passed as UTF-8 string) (Required) + +Options: + -h, --help Show help message + +> .\BleTools.Write.exe B8:27:EB:9C:F6:4C 00000000-6907-4437-8539-9218a9d54e29 00000001-6907-4437-8539-9218a9d54e29 Win +Value 'Win' written to service / characteristic 00000000-6907-4437-8539-9218a9d54e29 / 00000001-6907-4437-8539-9218a9d54e29 (device B8:27:EB:9C:F6:4C). +``` + +#### 4.3.3 Android clients +Almost all BLE clients on Android uses system api and works equally fine. The ones we checked are: +* [BlueTooth Terminal eDebugger](https://play.google.com/store/apps/details?id=com.e.debugger) +* [nRF Connect for Mobile](https://play.google.com/store/apps/details?id=no.nordicsemi.android.mcp) + +### 4.4. Consuming the API from your Python code The API is designed such that it may be consumed both via CLI and from within external Python code. More details on this [coming soon](https://github.com/quaxalber/bluetooth_2_usb/issues/16)! @@ -233,19 +368,21 @@ sudo ~/bluetooth_2_usb/scripts/uninstall.sh This is likely due to the limited power the Pi can draw from the host's USB port. Try these steps: +- check the output of `vcgencmd get_throttled` and `vcgencmd measure_temp` commands. The Throttled status should be `0x0` and the temperature has to be less than 80°C (on most devices average temperature is in range 40-50 °C). + - If available, connect your Pi to a USB 3 port on the host / target device (usually blue) or preferably USB-C. - + > [!IMPORTANT] > *Do not use* the blue (or black) USB-A ports *of your Pi* to connect. **This won't work.** > > *Do use* the small USB-C power port (in case of Pi 4B). For Pi Zero, use the data port to connect to the host and attach the power port to a dedicated power supply. - Try to [connect to the Pi via SSH](#31-prerequisites) instead of attaching a display directly and remove any unnecessary peripherals. - + - Install a [lite version](https://downloads.raspberrypi.org/raspios_lite_arm64/images/) of your OS on the Pi (without GUI) - + - For Pi 4B: Get a [USB-C Data/Power Splitter](https://thepihut.com/products/usb-c-data-power-splitter) and draw power from a dedicated power supply. This should ultimately resolve any power-related issues, and your Pi 4B will no longer be dependent on the host's power supply. - + > [!NOTE] > The Pi Zero is recommended to have a 1.2 A power supply for stable operation, the Pi Zero 2 requires 2.0 A and the Pi 4B even 3.0 A, while hosts may typically only supply up to 0.5/0.9 A through USB-A 2.0/3.0 ports. However, this may be sufficient depending on your specific soft- and hardware configuration. For more information see the [Raspberry Pi documentation](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#power-supply). @@ -254,20 +391,20 @@ This is likely due to the limited power the Pi can draw from the host's USB port This could be due to a number of reasons. Try these steps: - Verify that the service is running: - + ```console service bluetooth_2_usb status ``` - Verify that you specified the correct input devices in `bluetooth_2_usb.service` - + - Verify that your Bluetooth devices are paired, trusted, connected and *not* blocked: - + ```console bluetoothctl info A1:B2:C3:D4:E5:F6 ``` - + It should look like this: ```console @@ -290,26 +427,26 @@ This could be due to a number of reasons. Try these steps: UUID: Human Interface Device (00001812-0000-1000-8000-00805f9b34fb) UUID: Nordic UART Service (6e400001-b5a3-f393-e0a9-e50e24dcca9e) ``` - + > [!NOTE] > Replace `A1:B2:C3:D4:E5:F6` by your input device's Bluetooth MAC address - Reload and restart service: - + ```console sudo systemctl daemon-reload && sudo service bluetooth_2_usb restart ``` - Reboot Pi - + ```console sudo reboot ``` - Re-connect the Pi to the host and check that the cable is capable of transmitting data, not power only - + - Try a different USB port on the host - + - Try connecting to a different host ### 7.3. In bluetoothctl, my device is constantly switching on/off @@ -340,17 +477,45 @@ exit > [!NOTE] > Replace `0A:1B:2C:3D:4E:5F` by your Pi's Bluetooth controller's MAC and `A1:B2:C3:D4:E5:F6` by your input device's MAC -### 7.4. I have a different issue +### 7.4. There are occansional Bluetooth disconnects on Pi Zero 2 + +Please check that you're not using full-size metal case or cover. These are known to reduce Bluetooth connectivity range. If so, try to place your client device clother to the Pi Zero 2. + +Also, please check that you're use proper power source for your device (as specified in [7.1. The Pi keeps rebooting](#71-the-pi-keeps-rebooting-or-crashes-randomly)). + +### 7.5. There are occansional Wi-Fi disconnects on Pi Zero 2 + +There's a known issue with fresh Bookworm images. Sometimes the device does not respond to incoming Wi-Fi network requests. + +For this issue, try to disable power_save mode for the wlan0 as suggested [here](https://forums.raspberrypi.com/viewtopic.php?p=2024045&sid=41607aa3904668e8120e9188a29c474c#p2024045). + +**Occansional bluetooth disconnects** + +At first, please check that you're not usig full-size metal case or cover. These are known to reduce Bluetooth connectivity range. If so, try to place your client device clother to the RPi. + +Also, please check that you're use proper power source for your device. Raspberry Pi is known to have connectivity issues when underpowered. + +**Bluetooth reconnects takes too long** +Try to set +``` +FastConnectable = true +``` +in the `/etc/bluetooth/main.conf`. + +> [!NOTE] +> Enabling the FastConnectable option increases power consumption for the device. + +### 7.6. I have a different issue Here's a few things you could try: - Check the log files (default at `/var/log/bluetooth_2_usb/`) for errors - + > [!NOTE] > Logging to file requires the `-f` flag - You may also query the journal to inspect the service logs in real-time: - + ```console journalctl -u bluetooth_2_usb.service -n 50 -f ``` @@ -375,6 +540,10 @@ Here's a few things you could try: 23-12-16 15:52:24 [INFO] Activated relay for device /dev/input/event2, name "AceRK Mouse", phys "0a:1b:2c:3d:4e:5f" 23-12-16 15:52:24 [INFO] Activated relay for device /dev/input/event1, name "AceRK Keyboard", phys "0a:1b:2c:3d:4e:5f" 23-12-16 15:52:24 [INFO] Activated relay for device /dev/input/event0, name "vc4-hdmi", phys "vc4-hdmi/input0" + 23-12-16 15:52:27 [INFO] Activated BLE TO HID relay. Pairing required: True. Allows invalid input: False + 23-12-16 15:52:27 [INFO] Use 00000000-6907-4437-8539-9218a9d54e29 service / 00000001-6907-4437-8539-9218a9d54e29 characteristic to send keystrokes. + 23-12-16 15:52:27 [DEBUG] Starting GATT server + 23-12-16 15:52:27 [DEBUG] GATT server started ### Manually switched Pi's Bluetooth off ### 23-12-16 15:53:27 [CRITICAL] Connection to AceRK Keyboard lost [OSError(19, 'No such device')] 23-12-16 15:53:27 [CRITICAL] Connection to AceRK Mouse lost [OSError(19, 'No such device')] @@ -418,13 +587,20 @@ Here's a few things you could try: 23-12-16 15:54:50 [CRITICAL] vc4-hdmi was cancelled 23-12-16 15:54:50 [CRITICAL] AceRK Keyboard was cancelled 23-12-16 15:54:50 [CRITICAL] AceRK Mouse was cancelled + ### Sending keystrokes using BLE relay ### + 23-12-16 15:52:16 [DEBUG] Received input 'Win' for '00000001-6907-4437-8539-9218a9d54e29' + 23-12-16 15:52:16 [DEBUG] Keys to send: [Win] + 23-12-16 15:52:16 [DEBUG] Processed input 'Win' for '00000001-6907-4437-8539-9218a9d54e29' + 23-12-16 15:54:00 [DEBUG] Received input 'Ctrl-A Ctrl-C' for '00000001-6907-4437-8539-9218a9d54e29' + 23-12-16 15:54:00 [DEBUG] Keys to send: [Ctrl-A, Ctrl-C] + 23-12-16 15:54:00 [DEBUG] Processed input 'Ctrl-A Ctrl-C' for '00000001-6907-4437-8539-9218a9d54e29' ``` - Still not resolved? Double-check the [installation instructions](#3-installation) - + - For more help, open an [issue](https://github.com/quaxalber/bluetooth_2_usb/issues) in the [GitHub repository](https://github.com/quaxalber/bluetooth_2_usb) -### 7.5. Everything is working, but can it help me with Bitcoin mining? +### 7.7. Everything is working, but can it help me with Bitcoin mining? Absolutely! [Here's how](https://bit.ly/42BTC). @@ -449,4 +625,4 @@ This project is licensed under the MIT License - see the [LICENSE](https://githu * [Mike Redrobe](https://github.com/mikerr/pihidproxy) for the idea and the basic code logic and [HeuristicPerson's bluetooth_2_hid](https://github.com/HeuristicPerson/bluetooth_2_hid) based off this. * [Georgi Valkov](https://github.com/gvalkov) for [python-evdev](https://github.com/gvalkov/python-evdev) making reading input devices a walk in the park. * The folks at [Adafruit](https://www.adafruit.com/) for [CircuitPython HID](https://github.com/adafruit/Adafruit_CircuitPython_HID) and [Blinka](https://github.com/quaxalber/Adafruit_Blinka/blob/main/src/usb_hid.py) providing super smooth access to USB gadgets. -* Special thanks to the open-source community for various other libraries and tools. +* Special thanks to the open-source community for various other libraries and tools. \ No newline at end of file diff --git a/bluetooth_2_usb.py b/bluetooth_2_usb.py index c1183c79..5f81205d 100755 --- a/bluetooth_2_usb.py +++ b/bluetooth_2_usb.py @@ -10,6 +10,7 @@ from src.bluetooth_2_usb.args import parse_args from src.bluetooth_2_usb.logging import add_file_handler, get_logger from src.bluetooth_2_usb.relay import RelayController, async_list_input_devices +from src.bluetooth_2_usb.relay_ble import RelayBleController logger = get_logger() @@ -49,9 +50,19 @@ async def main() -> NoReturn: logger.debug(log_handlers_message) logger.info(f"Launching {VERSIONED_NAME}") - controller = RelayController(args.device_ids, args.auto_discover, args.grab_devices) - await controller.async_relay_devices() + tasks = [] + if args.no_input_relay & args.no_ble_relay: + raise RuntimeError("Both input and BLE realys are disabled.") + if not args.no_input_relay: + input_controller = RelayController(args.device_ids, args.auto_discover, args.grab_devices) + tasks.append(input_controller.async_relay_devices()) + + if not args.no_ble_relay: + ble_controller = RelayBleController(args.accept_non_trusted, args.partial_parse_ble_command) + tasks.append(ble_controller.async_relay_ble()) + + await asyncio.gather(*tasks) async def async_list_devices(): for dev in await async_list_input_devices(): diff --git a/requirements.client.txt b/requirements.client.txt new file mode 100644 index 00000000..38d5d8b6 --- /dev/null +++ b/requirements.client.txt @@ -0,0 +1,3 @@ +quax-circuitpython-hid==6.0.2.post1 +bleak==0.21.1 +parameterized==0.9.0 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 28af68d4..eceb1661 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,5 @@ quax-circuitpython-hid==6.0.2.post1 rpi-ws281x==5.0.0 RPi.GPIO==0.7.1 sysv-ipc==1.1.0 +dbus-next==0.2.3 +bless==0.2.5 diff --git a/scripts/update.sh b/scripts/update.sh index 5f91fc2a..7606c3fd 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Update Bluetooth 2 USB to the latest stable GitHub version. Handles updating submodules, if required. +# Update Bluetooth 2 USB to the latest stable GitHub version. Handles updating submodules, if required. # Temporarily disable history expansion set +H @@ -56,12 +56,13 @@ current_group=$(stat -c '%G' .) || abort_update "Failed retrieving current group current_branch=$(git symbolic-ref --short HEAD) || abort_update "Failed retrieving currently checked out branch." { - scripts/uninstall.sh && - cd .. && - rm -rf "${base_directory}" && - git clone https://github.com/quaxalber/bluetooth_2_usb.git && - cd "${base_directory}" && + scripts/uninstall.sh && + cd .. && + rm -rf "${base_directory}" && +# git clone https://github.com/quaxalber/bluetooth_2_usb.git && + git clone https://github.com/ig-sinicyn/bluetooth_2_usb.git && + cd "${base_directory}" && git checkout "${current_branch}" && chown -R ${current_user}:${current_group} "${base_directory}" && - scripts/install.sh ; + scripts/install.sh ; } || abort_update "Failed updating Bluetooth 2 USB" diff --git a/src/bluetooth_2_usb/args.py b/src/bluetooth_2_usb/args.py index f7cf7a46..c291daca 100644 --- a/src/bluetooth_2_usb/args.py +++ b/src/bluetooth_2_usb/args.py @@ -11,7 +11,7 @@ def __init__(self, *args, **kwargs) -> None: super().__init__( *args, add_help=False, - description="Bluetooth to USB HID relay. Handles Bluetooth keyboard and mouse events from multiple input devices and translates them to USB using Linux's gadget mode.", + description="Bluetooth to USB HID relay. Handles Bluetooth keyboard and mouse events from multiple input devices and translates them to USB using Linux's gadget mode. Handles keycodes sent to BLE GATT characteristic and translates them to USB using Linux's gadget mode.", formatter_class=argparse.RawTextHelpFormatter, **kwargs, ) @@ -47,6 +47,30 @@ def _add_arguments(self) -> None: default=False, help="List all available input devices and exit.", ) + self.add_argument( + "--no-input-relay", + action="store_true", + default=False, + help="Disable input relay mode (sends input keys to USB HID device)\nDefault: input relay enabled.", + ) + self.add_argument( + "--no-ble-relay", + action="store_true", + default=False, + help="Disable BLE relay mode (BLE server that sends keystrokes to USB HID device)\nDefault: BLE relay enabled.", + ) + self.add_argument( + "--accept-non-trusted", + action="store_true", + default=False, + help="UNSAFE! Accepts non-trusted BLE relay clients.", + ) + self.add_argument( + "--partial-parse-ble-command", + action="store_true", + default=False, + help="Enables partial parsing of GATT characteristic input (ignores unknown key names).", + ) self.add_argument( "--log_to_file", "-f", @@ -104,6 +128,10 @@ class Arguments: "_auto_discover", "_grab_devices", "_list_devices", + "_no_input_relay", + "_no_ble_relay", + "_accept_non_trusted", + "_partial_parse_ble_command", "_log_to_file", "_log_path", "_debug", @@ -116,6 +144,10 @@ def __init__( auto_discover: bool, grab_devices: bool, list_devices: bool, + no_input_relay: bool, + no_ble_relay: bool, + accept_non_trusted: bool, + partial_parse_ble_command: bool, log_to_file: bool, log_path: str, debug: bool, @@ -125,6 +157,10 @@ def __init__( self._auto_discover = auto_discover self._grab_devices = grab_devices self._list_devices = list_devices + self._no_input_relay = no_input_relay + self._no_ble_relay = no_ble_relay + self._accept_non_trusted = accept_non_trusted + self._partial_parse_ble_command = partial_parse_ble_command self._log_to_file = log_to_file self._log_path = log_path self._debug = debug @@ -146,6 +182,22 @@ def grab_devices(self) -> bool: def list_devices(self) -> bool: return self._list_devices + @property + def no_input_relay(self) -> bool: + return self._no_input_relay + + @property + def no_ble_relay(self) -> bool: + return self._no_ble_relay + + @property + def accept_non_trusted(self) -> bool: + return self._accept_non_trusted + + @property + def partial_parse_ble_command(self) -> bool: + return self._partial_parse_ble_command + @property def log_to_file(self) -> bool: return self._log_to_file @@ -182,6 +234,10 @@ def parse_args() -> Arguments: auto_discover=args.auto_discover, grab_devices=args.grab_devices, list_devices=args.list_devices, + no_input_relay=args.no_input_relay, + no_ble_relay=args.no_ble_relay, + accept_non_trusted=args.accept_non_trusted, + partial_parse_ble_command=args.partial_parse_ble_command, log_to_file=args.log_to_file, log_path=args.log_path, debug=args.debug, diff --git a/src/bluetooth_2_usb/relay_ble.py b/src/bluetooth_2_usb/relay_ble.py new file mode 100644 index 00000000..1f4bc461 --- /dev/null +++ b/src/bluetooth_2_usb/relay_ble.py @@ -0,0 +1,170 @@ +import asyncio +from enum import Flag +from typing import NoReturn, Dict +from adafruit_hid.keyboard import Keyboard +import usb_hid +from usb_hid import Device + +from bless import ( + BlessServer, + BlessGATTCharacteristic, + GATTAttributePermissions +) +from bleak.backends.bluezdbus.characteristic import ( # type: ignore + _GattCharacteristicsFlagsEnum +) + +from .logging import get_logger + +from src.bluetooth_2_usb.shortcut_parser import ShortcutParser +from src.bluetooth_2_usb.relay import (all_gadgets_ready, init_usb_gadgets) + + +class CustomGATTCharacteristicProperties(Flag): + broadcast = 0x00001 + read = 0x00002 + write_without_response = 0x00004 + write = 0x00008 + notify = 0x00010 + indicate = 0x00020 + authenticated_signed_writes = 0x00040 + extended_properties = 0x00080 + reliable_write = 0x00100 + writable_auxiliaries = 0x00200 + encrypt_read = 0x00400 + encrypt_write = 0x00800 + encrypt_authenticated_read = 0x01000 + encrypt_authenticated_write = 0x02000 + secure_read = 0x04000 #(Server only) + secure_write = 0x08000 #(Server only) + authorize = 0x10000 + + +# HACK: redefine disabled characteristic mapping for bless to bluezdbus backend +# see https://github.com/hbldh/bleak/blob/master/bleak/backends/bluezdbus/characteristic.py#L20-L26 +_AddCustomGattCharacteristicsFlagsEnum: dict[int, str] = { + 0x00400: "encrypt-read", + 0x00800: "encrypt-write", + 0x01000: "encrypt-authenticated-read", + 0x02000: "encrypt-authenticated-write", + 0x04000: "secure-read", #(Server only) + 0x08000: "secure-write", #(Server only) + 0x10000: "authorize", +} +for key in _AddCustomGattCharacteristicsFlagsEnum: + _GattCharacteristicsFlagsEnum[key] = _AddCustomGattCharacteristicsFlagsEnum[key] + +_logger = get_logger() + +GATT_SERVER_NAME = f"Bluetooth 2 USB" +GATT_SERVICE_ID = "00000000-6907-4437-8539-9218a9d54e29" +GATT_CHARACTERISTIC_ID = "00000001-6907-4437-8539-9218a9d54e29" + + +class BleRelay: + + def __init__( + self, + accept_non_trusted: bool = False, + partial_parse: bool = False) -> None: + self._accept_non_trusted = accept_non_trusted + self._partial_parse = partial_parse + self._shortcut_parser = ShortcutParser() + if not all_gadgets_ready(): + init_usb_gadgets() + enabled_devices: list[Device] = list(usb_hid.devices) # type: ignore + self._keyboard_gadget = Keyboard(enabled_devices) + + def __str__(self) -> str: + return "BLE TO HID relay" + + async def async_relay_events_loop(self) -> NoReturn: + gatt_properties = (CustomGATTCharacteristicProperties.encrypt_authenticated_read + | CustomGATTCharacteristicProperties.encrypt_authenticated_write) + gatt_permissions = (GATTAttributePermissions.read_encryption_required + | GATTAttributePermissions.write_encryption_required) + + if (self._accept_non_trusted): + gatt_properties = CustomGATTCharacteristicProperties.read | CustomGATTCharacteristicProperties.write + gatt_permissions = GATTAttributePermissions.readable | GATTAttributePermissions.writeable + + # Instantiate the server + gatt: Dict = { + GATT_SERVICE_ID: { + GATT_CHARACTERISTIC_ID: { + "Properties": gatt_properties, + "Permissions": gatt_permissions, + "Value": None + }, + } + } + server = BlessServer(name=GATT_SERVER_NAME) + server.read_request_func = self._read_request + server.write_request_func = self._write_request + + _logger.debug("Starting GATT server") + await server.add_gatt(gatt) + await server.start() + _logger.debug("GATT server started") + + try: + while True: + await asyncio.sleep(0.5) + except* Exception: + _logger.debug("GATT server stopping") + await server.stop() + _logger.debug("GATT server stopped") + + def _read_request( + self, + characteristic: BlessGATTCharacteristic, + **kwargs + ) -> bytearray: + if characteristic.uuid != GATT_CHARACTERISTIC_ID: + raise RuntimeError(f"Invalid characteristic '{characteristic.uuid}'") + _logger.debug(f"Read last input value '{characteristic.value}' for '{characteristic.uuid}'") + return characteristic.value + + def _write_request( + self, + characteristic: BlessGATTCharacteristic, + value: bytearray, + **kwargs + ): + if characteristic.uuid != GATT_CHARACTERISTIC_ID: + raise RuntimeError(f"Invalid characteristic '{characteristic.uuid}'") + input = value.decode() + _logger.debug(f"Received input '{input}' for '{characteristic.uuid}'") + parsed_input = self._shortcut_parser.parse_command(input, raise_error=not self._partial_parse) + if len(parsed_input) == 0: + _logger.debug(f"Ignoring invalid input '{input}'.") + return + + _logger.debug(f"Keys to send: {parsed_input}") + for shortcut in parsed_input: + self._keyboard_gadget.send(*shortcut.keycodes) + characteristic.value = value + + _logger.debug(f"Processed input '{input}' for '{characteristic.uuid}'/") + + +class RelayBleController: + """ + This class serves as a BLE HID relay to handle Bluetooth GATT characteristic write events and translate them to USB. + """ + + def __init__( + self, + accept_non_trusted: bool = False, + partial_parse: bool = False) -> None: + self._partial_parse = partial_parse + self._accept_non_trusted = accept_non_trusted + + async def async_relay_ble(self) -> NoReturn: + try: + relay = BleRelay(self._accept_non_trusted, self._partial_parse) + _logger.info(f"Activated {relay}. Pairing required: {not self._accept_non_trusted}. Allows invalid input: {self._partial_parse}") + _logger.info(f"Use {GATT_SERVICE_ID} service / {GATT_CHARACTERISTIC_ID} characteristic to send keystrokes.") + await relay.async_relay_events_loop() + except* Exception: + _logger.exception("Error(s) in relay") \ No newline at end of file diff --git a/src/bluetooth_2_usb/shortcut_parser.py b/src/bluetooth_2_usb/shortcut_parser.py new file mode 100644 index 00000000..b3fa3cfc --- /dev/null +++ b/src/bluetooth_2_usb/shortcut_parser.py @@ -0,0 +1,202 @@ +import re +from typing import Dict +from adafruit_hid.keycode import Keycode + +class ParsedShortcut: + """ + Pair of shortcut (keycode combination) and formatted shortcut description. + """ + + def __init__(self, keycodes: list[int], description: str): + self._keycodes = keycodes + self._description = description + + @property + def keycodes(self) -> list[int]: + return self._keycodes + + @property + def description(self) -> str: + return self._description + + @property + def is_empty(self) -> bool: + return len(self._keycodes) == 0 + + def __str__(self) -> str: + return self._description + + def __repr__(self) -> str: + return self._description + + +class ShortcutParser: + """ + Performs shortcut parsing. + """ + + # Preferred key names to be used on shortcut formatting + # Also used on shortcut parsing + _preferred_keycode_names: dict[int, str] = { + Keycode.ONE: "1", + Keycode.TWO: "2", + Keycode.THREE: "3", + Keycode.FOUR: "4", + Keycode.FIVE: "5", + Keycode.SIX: "6", + Keycode.SEVEN: "7", + Keycode.EIGHT: "8", + Keycode.NINE: "9", + Keycode.ZERO: "0", + Keycode.ESCAPE: "ESC", + Keycode.EQUALS: "=", + Keycode.LEFT_BRACKET: "[", + Keycode.RIGHT_BRACKET: "]", + Keycode.BACKSLASH: "\\", + Keycode.QUOTE: "'", + Keycode.GRAVE_ACCENT: "`", + Keycode.PERIOD: ".", + Keycode.FORWARD_SLASH: "/", + Keycode.PRINT_SCREEN: "PRTSCR", + Keycode.PAUSE : "BREAK", + Keycode.INSERT: "INS", + Keycode.PAGE_UP: "PGUP", + Keycode.DELETE: "DEL", + Keycode.PAGE_DOWN: "PGDOWN", + Keycode.RIGHT_ARROW: "RIGHT", + Keycode.LEFT_ARROW: "LEFT", + Keycode.DOWN_ARROW: "DOWN", + Keycode.UP_ARROW: "UP", + Keycode.APPLICATION: "APP", + Keycode.CONTROL: "CTRL", + Keycode.SHIFT: "SHIFT", + Keycode.ALT: "ALT", + Keycode.GUI: "WIN" + } + + # Additional key names used on shortcut parsing + _additional_keycode_aliases: dict[str, int] = { + "EQUAL": Keycode.EQUALS, + "BACK": Keycode.BACKSPACE, + "LEFTBRACE": Keycode.LEFT_BRACKET, + "RIGHTBRACE": Keycode.RIGHT_BRACKET, + "LBRACE": Keycode.LEFT_BRACKET, + "RBRACE": Keycode.RIGHT_BRACKET, + "GRAVE": Keycode.GRAVE_ACCENT, + "SLASH": Keycode.FORWARD_SLASH, + "CAPSLOCK": Keycode.CAPS_LOCK, + "CAPITAL": Keycode.CAPS_LOCK, + "SCROLLLOCK": Keycode.SCROLL_LOCK, + "PAGEUP": Keycode.PAGE_UP, + "PAGEDOWN": Keycode.PAGE_DOWN, + "COMPOSE": Keycode.APPLICATION, + "LCTRL": Keycode.LEFT_CONTROL, + "RCTRL": Keycode.RIGHT_CONTROL, + "LSHIFT": Keycode.LEFT_SHIFT, + "RSHIFT": Keycode.RIGHT_SHIFT, + "LALT": Keycode.LEFT_ALT, + "RALT": Keycode.RIGHT_ALT, + "LWIN": Keycode.LEFT_GUI, + "RWIN": Keycode.RIGHT_GUI, + "META": Keycode.GUI, + "LMETA": Keycode.LEFT_GUI, + "RMETA": Keycode.RIGHT_GUI, + } + + # Used to split shortcut_command to individual shortcuts + _command_split_regex = re.compile(r'[,;\s]+') + + # Used to split shortcut to series of keycodes + _shortcut_split_regex = re.compile(r'[-+]+') + + def __init__(self) -> None: + # Used to map key name to Keycode + self._key_codes: Dict = dict[str, int]() + # Used to map Keycode to key name + self._key_names: Dict = dict[int, str]() + # fill mappings from adafruit_hid.keycode.Keycode + for field in dir(Keycode): + if not field.startswith("__"): + field_value = getattr(Keycode, field) + if isinstance(field_value, int): + self._key_codes[field] = field_value + self._key_names[field_value] = field + # add key aliases + for alias in self._additional_keycode_aliases: + keycode = self._additional_keycode_aliases[alias] + self._key_codes[alias] = keycode + # add preferred key names + for keycode in self._preferred_keycode_names: + keycode_name = self._preferred_keycode_names[keycode] + self._key_codes[keycode_name] = keycode + self._key_names[keycode] = keycode_name + + # Accepts a command (string representation of multiple schortcuts) and returns a list of schortcuts for it + # Raises an ValueError if raise_error is on and command cannot be parsed + def parse_command(self, shortcut_command: str, raise_error: bool = True) -> list[ParsedShortcut]: + """ + Parses a command (a series of shortcuts splitted by whitespace, ',' or ';' separators). + + A shortcut is combination of one or more keycodes splitted by '-' or '+' separators. + Keycodes are case-insensitive and accepts values from adafruit_hid.keycode.Keycode enum + as well as Windows Virtual-Key Codes names (https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes, 'VK_' prefix should be removed) + + Ignores invalid keycodes if raise_error arg is set to False (default: True). + + Examples + -------- + >>> parser.parse_command('Ctrl-A Del') + ["Ctrl-A", "Del"] + + >>> parser.parse_command('CCtrl-A, Del, Alt+Tab', raise_error: False) + ["Del", "Alt-Tab"] + """ + + shortcuts = [] + for shortcut_candidate in self._command_split_regex.split(shortcut_command): + try: + shortcut = self.parse_shortcut(shortcut_candidate, raise_error) + if shortcut: + shortcuts.append(shortcut) + except ValueError as ex: + raise ValueError(f"Cannot parse command {shortcut_command}: {ex.args[0]}") + return shortcuts + + # Parses shortcut. On failure returns None or raises an ValueError depending on raise_error + def parse_shortcut(self, shortcut: str, raise_error: bool = True) -> ParsedShortcut | None: + """ + Parses a shortcut (a combination of one or more keycodes splitted by '-' or '+' separators). + + A shortcut is combination of one or more keycodes splitted by '-' or '+' separators. + Keycodes are case-insensitive and accepts values from adafruit_hid.keycode.Keycode enum + as well as Windows Virtual-Key Codes names (https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes, 'VK_' prefix should be trimmed) + + Ignores invalid keycodes if raise_error arg is set to False (default: True). + + Examples + -------- + >>> parser.parse_shortcut('Ctrl-A') + ParsedShortcut: + keycodes: [Keycode.CONTROL, Keycode.A] + description: "Ctrl-A" + + >>> parser.parse_shortcut('Ctrl-AA') + None + """ + + keycodes = [] + keynames = [] + for key_candidate in map(str.upper, self._shortcut_split_regex.split(shortcut)): + if key_candidate in self._key_codes: + keycode = self._key_codes[key_candidate] + keycodes.append(keycode) + + keyname = self._key_names[keycode] + keynames.append(keyname.capitalize()) + elif raise_error: + raise ValueError(f"Unknown key {key_candidate} in shortcut {shortcut}") + else: + return None + if not keycodes: + return None + return ParsedShortcut(keycodes, "-".join(keynames)) \ No newline at end of file diff --git a/src/gatt_client/client.py b/src/gatt_client/client.py new file mode 100644 index 00000000..099b9119 --- /dev/null +++ b/src/gatt_client/client.py @@ -0,0 +1,89 @@ +# BASED ON https://github.com/hbldh/bleak/blob/develop/examples/philips_hue.py + +import sys +import argparse +import asyncio +from uuid import UUID +from bleak import BleakClient + + +class CustomArgumentParser(argparse.ArgumentParser): + def __init__(self, *args, **kwargs) -> None: + super().__init__( + *args, + description="GATT sample client.", + formatter_class=argparse.RawTextHelpFormatter, + **kwargs) + self.add_argument( + "--address", + "-a", + type=str, + default=None, + help="MAC address of target device\nDefault: None") + self.add_argument( + "--characteristic", + "-c", + type=UUID, + default=None, + help="Target GATT characteristic.\nDefault: disabled") + self.add_argument( + "value", + type=str, + help="Value to be written to the target characteristic. The value will be passed as an UTF-8 string") + + +class Arguments: + + def __init__( + self, + address: str, + characteristic: UUID, + value: str, + ) -> None: + self._address = address + self._characteristic = characteristic + self._value = value + + @property + def address(self) -> str: + return self._address + + @property + def characteristic(self) -> UUID: + return self._characteristic + + @property + def value(self) -> str: + return self._value + + +def parse_args() -> Arguments: + x = sys.argv + parser = CustomArgumentParser() + args = parser.parse_args() + + # Check if no arguments were provided + if len(sys.argv) == 1: + sys.exit(1) + + return Arguments( + address=args.address, + characteristic=args.characteristic, + value=args.value) + + +async def main(address: str, characteristic: UUID, value: str): + async with BleakClient(address) as client: + print(f"Connected to {address}: {client.is_connected}") + + paired = await client.pair(protection_level=2) + print(f"Paired: {paired}") + + print(f"Writing value '{value}'") + await client.write_gatt_char(characteristic, value.encode(encoding = 'UTF-8', errors = 'strict'), response=False) + print(f"Value '{value}' written to characteristic '{characteristic}'") + + +if __name__ == "__main__": + args = parse_args() + asyncio.run(main(args.address, args.characteristic, args.value)) \ No newline at end of file diff --git a/tests/bluetooth_2_usb/test_shortcut_parser.py b/tests/bluetooth_2_usb/test_shortcut_parser.py new file mode 100644 index 00000000..78eeccc4 --- /dev/null +++ b/tests/bluetooth_2_usb/test_shortcut_parser.py @@ -0,0 +1,122 @@ +# HACK: the main module code import some modules that are not available on windows and are not needed for tests. +# the modules are not inclided into requirements.client.txt list and we ldo ignore them on import +# BASED ON: https://stackoverflow.com/a/6077117/318263 +import builtins +import types +from types import ModuleType +from typing import Mapping, Sequence + +class DummyModule(ModuleType): + def __getattr__(self, key): + return None + __all__ = [] # support wildcard imports + +_bad_modules = { 'evdev' } + +def tryimport( + name: str, + globals: Mapping[str, object] | None = None, + locals: Mapping[str, object] | None = None, + fromlist: Sequence[str] = (), + level: int = 0, +) -> types.ModuleType: + try: + return realimport(name, globals, locals, fromlist, level) + except ImportError: + if (name in _bad_modules): + return DummyModule(name) + raise + +realimport, builtins.__import__ = builtins.__import__, tryimport + +import sys +import os +sys.path.append(os.path.abspath('.')) + +import unittest +from parameterized import parameterized +from adafruit_hid.keycode import Keycode +from src.bluetooth_2_usb.shortcut_parser import ShortcutParser, ParsedShortcut + + +class TestShortcutParser(unittest.TestCase): + + @parameterized.expand([ + ('A', [Keycode.A], "A"), + ('CONTROL-C', [Keycode.CONTROL, Keycode.C], "Ctrl-C"), + ('CONTROL+C', [Keycode.CONTROL, Keycode.C], "Ctrl-C"), + ('GUI-Two', [Keycode.GUI, Keycode.TWO], "Win-2"), + ('control-+-ins', [Keycode.CONTROL, Keycode.INSERT], "Ctrl-Ins"), + ('meta-shift-pause', [Keycode.WINDOWS, Keycode.SHIFT, Keycode.PAUSE], "Win-Shift-Break"), + ('CONTROL+ALT+DELETE', [Keycode.CONTROL, Keycode.ALT, Keycode.DELETE], "Ctrl-Alt-Del"), + ('CTRL+ALT+DEL', [Keycode.CONTROL, Keycode.ALT, Keycode.DELETE], "Ctrl-Alt-Del") + ]) + def test_parse_shortcut(self, input: str, expected_keycodes: list[int], expected_description: str): + parser = ShortcutParser() + shortcut = parser.parse_shortcut(input) + self.assertEqual(shortcut.keycodes, expected_keycodes) + self.assertEqual(shortcut.description, expected_description) + + @parameterized.expand([ + ('A', [[Keycode.A]], ["A"]), + ('A B', [[Keycode.A], [Keycode.B]], ["A", "B"]), + ('A\tB', [[Keycode.A], [Keycode.B]], ["A", "B"]), + ('A;B', [[Keycode.A], [Keycode.B]], ["A", "B"]), + ('A,B', [[Keycode.A], [Keycode.B]], ["A", "B"]), + ('control-ins; ;Del', [[Keycode.CONTROL, Keycode.INSERT], [Keycode.DELETE]], ["Ctrl-Ins", "Del"]), + ('H I Shift-ONE', [[Keycode.H], [Keycode.I], [Keycode.SHIFT, Keycode.ONE]], ["H", "I", "Shift-1"]), + ('Ctrl+A,;,Delete', [[Keycode.CONTROL, Keycode.A], [Keycode.DELETE]], ["Ctrl-A", "Del"]) + ]) + def test_parse_command(self, input: str, expected_keycodes: list[list[int]], expected_description: list[str]): + parser = ShortcutParser() + shortcuts = parser.parse_command(input) + for i in range(0, len(shortcuts)): + self.assertEqual(shortcuts[i].keycodes, expected_keycodes[i]) + self.assertEqual(shortcuts[i].description, expected_description[i]) + + @parameterized.expand([ + ('WWW'), + ('A+B=C'), + ('control-aaaa'), + ('me-me-me-2') + ]) + def test_parse_bad_shortcut_silent(self, input: str): + parser = ShortcutParser() + shortcut = parser.parse_shortcut(input, raise_error=False) + self.assertIsNone(shortcut) + + @parameterized.expand([ + ('WWW'), + ('A+B=C'), + ('control-aaaa'), + ('me-me-me-2') + ]) + def test_parse_bad_shortcut_fails(self, input: str): + parser = ShortcutParser() + self.assertRaises(ValueError, parser.parse_shortcut, input, True) + + @parameterized.expand([ + ('AA', [], []), + ('control=ins; Del', [[Keycode.DELETE]], ["Del"]), + ('H: I Shift-ONE', [[Keycode.I], [Keycode.SHIFT, Keycode.ONE]], ["I", "Shift-1"]), + ('Ctrl+Aa,Delet', [], []) + ]) + def test_parse_bad_command_silent(self, input: str, expected_keycodes: list[list[int]], expected_description: list[str]): + parser = ShortcutParser() + shortcuts = parser.parse_command(input, raise_error=False) + for i in range(0, len(shortcuts)): + self.assertEqual(shortcuts[i].keycodes, expected_keycodes[i]) + self.assertEqual(shortcuts[i].description, expected_description[i]) + + @parameterized.expand([ + ('AA'), + ('control=ins; Del'), + ('H: I Shift-ONE'), + ('Ctrl+Aa,Delet') + ]) + def test_parse_bad_command_fails(self, input: str): + parser = ShortcutParser() + self.assertRaises(ValueError, parser.parse_command, input, True) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file