Skip to content

Instantly share code, notes, and snippets.

@ukBaz

ukBaz/README.md Secret

Last active Sep 3, 2022
Embed
What would you like to do?
Create a Bluetooth HID server

This was an experiment to turn a Raspberry Pi into a Human Interface Device (HID). A keyboard to be more precise.

I followed the instructions at the following location to get me started: http://yetanotherpointlesstechblog.blogspot.com/2016/04/emulating-bluetooth-keyboard-with.html

I wanted to move to Python3 and tidy things up on the Bluetooth side to bring it in to line with current ways things are done in BlueZ.

Configure Raspberry Pi.

These instructions assuming you have BlueZ 5.43 installed. You can check this with:

$ bluetoothctl -v
5.43

Ensure Raspberry Pi is at the latest version:

sudo apt-get update
sudo apt-get upgrade
sudo apt-get dist-upgrade

Check that the packages required for this are installed

sudo apt-get install python3-dbus
sudo pip install evdev

Here is an outline of things I changed:

Moved to Python3

I wanted to do this because not only is it a good thing to do but it also allowed some of the dependancies to be removed. After Python 3.3 Bluetooth sockets are supported in the native Python installs. The downside to this is that there are clear distinctions between str and bytes in the code. For me, this broke the keyboard client. This is what required the biggest re-write to get Python3 working.

Reconfigure the Bluetooth Daemon

The instructions worked that were provided but things have moved on a little bit. To stop the Bluetooth daemon running then the following command is preferred:

sudo service bluetooth stop

The input Bluetooth plugin needs to be removed so that it does not grab the sockets we require access to. As the original author says the way this was documented could be improved. If you want to restart the daemon (without the input plugin) from the command line then the following would seem the preferred:

sudo /usr/lib/bluetooth/bluetoothd -P input

If you want to make this the default for this Raspberry Pi then modify the /lib/systemd/system/bluetooth.service file. You will need to change the Service line from:

ExecStart=/usr/lib/bluetooth/bluetoothd

to

ExecStart=/usr/lib/bluetooth/bluetoothd -P input

Configure D-Bus

When a new service is created on the D-Bus, this service needs to be configured.

sudo cp org.yaptb.btkkbservice.conf /etc/dbus-1/system.d

Event loop

The original article used Gtk for the event loop. I changed it to the library that I normally use and this removed the warning the original author was getting.

hciconfig

This command has been deprecated in the BlueZ project. https://wiki.archlinux.org/index.php/bluetooth#Deprecated_BlueZ_tools

In the setup of the original article the hciconfig command used to get the BD address. I have modified this so that the code queries the adapter and gets the address.

There were also os.system calls to hciconfig from within the Python. With the new BlueZ D-Bus interface these are unnecessary and have been replaced with D-Bus calls.

Sockets

Moving to a new version (> 3.3?) of Python will not require the import bluetooth line that was there previously. More information on the Python socket support of Bluetooth is available at: https://docs.python.org/3.4/library/socket.html#socket-families

Registering of Profile

As the original author noted, the registering of the HID profile does not seem to work as documented at: https://git.kernel.org/pub/scm/bluetooth/bluez.git/tree/doc/profile-api.txt The NewConnection method did not seem to get called on a new connection being made. Requests to the BlueZ mailing list did not seem to yield any insight as to why this is.

Pairing

With the settings used in this setup the pairing steps described in the original tutorial should not be required. While this is probably not a sensible choice for a real situation, for this experiment I chose convenience over security.

Below is a transcript from the two terminal I had open for this experiment.

Terminal 1

pi@raspberrypi:~/python/bluetooth_hid/btkeyboard/server $ sudo service bluetooth stop
pi@raspberrypi:~/python/bluetooth_hid/btkeyboard/server $ sudo /usr/lib/bluetooth/bluetoothd -P input &
pi@raspberrypi:~/python/bluetooth_hid/btkeyboard/server $ sudo python3 btk_server.py
Setting up service
Setting up BT device
Configuring for name BT_HID_Keyboard
Configuring Bluez Profile
Reading service record
Profile registered
Waiting for connections

Scan for the keyboard Pi and connect from main computer

8C:2D:AA:44:0E:3A connected on the control socket
8C:2D:AA:44:0E:3A connected on the interrupt channel

Terminal 2

pi@raspberrypi:~/python/bluetooth_hid/btkeyboard/keyboard $ python3 kb_client.py
Setting up keyboard
found a keyboard
starting event loop
Listening...
#!/usr/bin/python3
"""
Bluetooth HID keyboard emulator DBUS Service
Original idea taken from:
http://yetanotherpointlesstechblog.blogspot.com/2016/04/emulating-bluetooth-keyboard-with.html
Moved to Python 3 and tested with BlueZ 5.43
"""
import os
import sys
import dbus
import dbus.service
import socket
from gi.repository import GLib
from dbus.mainloop.glib import DBusGMainLoop
class HumanInterfaceDeviceProfile(dbus.service.Object):
"""
BlueZ D-Bus Profile for HID
"""
fd = -1
@dbus.service.method('org.bluez.Profile1',
in_signature='', out_signature='')
def Release(self):
print('Release')
mainloop.quit()
@dbus.service.method('org.bluez.Profile1',
in_signature='oha{sv}', out_signature='')
def NewConnection(self, path, fd, properties):
self.fd = fd.take()
print('NewConnection({}, {})'.format(path, self.fd))
for key in properties.keys():
if key == 'Version' or key == 'Features':
print(' {} = 0x{:04x}'.format(key,
properties[key]))
else:
print(' {} = {}'.format(key, properties[key]))
@dbus.service.method('org.bluez.Profile1',
in_signature='o', out_signature='')
def RequestDisconnection(self, path):
print('RequestDisconnection {}'.format(path))
if self.fd > 0:
os.close(self.fd)
self.fd = -1
class BTKbDevice:
"""
create a bluetooth device to emulate a HID keyboard
"""
MY_DEV_NAME = 'BT_HID_Keyboard'
# Service port - must match port configured in SDP record
P_CTRL = 17
# Service port - must match port configured in SDP record#Interrrupt port
P_INTR = 19
# BlueZ dbus
PROFILE_DBUS_PATH = '/bluez/yaptb/btkb_profile'
ADAPTER_IFACE = 'org.bluez.Adapter1'
DEVICE_INTERFACE = 'org.bluez.Device1'
DBUS_PROP_IFACE = 'org.freedesktop.DBus.Properties'
DBUS_OM_IFACE = 'org.freedesktop.DBus.ObjectManager'
# file path of the sdp record to laod
install_dir = os.path.dirname(os.path.realpath(__file__))
SDP_RECORD_PATH = os.path.join(install_dir,
'sdp_record.xml')
# UUID for HID service (1124)
# https://www.bluetooth.com/specifications/assigned-numbers/service-discovery
UUID = '00001124-0000-1000-8000-00805f9b34fb'
def __init__(self, hci=0):
self.scontrol = None
self.ccontrol = None # Socket object for control
self.sinterrupt = None
self.cinterrupt = None # Socket object for interrupt
self.dev_path = '/org/bluez/hci{}'.format(hci)
print('Setting up BT device')
self.bus = dbus.SystemBus()
self.adapter_methods = dbus.Interface(
self.bus.get_object('org.bluez',
self.dev_path),
self.ADAPTER_IFACE)
self.adapter_property = dbus.Interface(
self.bus.get_object('org.bluez',
self.dev_path),
self.DBUS_PROP_IFACE)
self.bus.add_signal_receiver(self.interfaces_added,
dbus_interface=self.DBUS_OM_IFACE,
signal_name='InterfacesAdded')
self.bus.add_signal_receiver(self._properties_changed,
dbus_interface=self.DBUS_PROP_IFACE,
signal_name='PropertiesChanged',
arg0=self.DEVICE_INTERFACE,
path_keyword='path')
print('Configuring for name {}'.format(BTKbDevice.MY_DEV_NAME))
self.config_hid_profile()
# set the Bluetooth device configuration
self.alias = BTKbDevice.MY_DEV_NAME
self.discoverabletimeout = 0
self.discoverable = True
def interfaces_added(self):
pass
def _properties_changed(self, interface, changed, invalidated, path):
if self.on_disconnect is not None:
if 'Connected' in changed:
if not changed['Connected']:
self.on_disconnect()
def on_disconnect(self):
print('The client has been disconnect')
self.listen()
@property
def address(self):
"""Return the adapter MAC address."""
return self.adapter_property.Get(self.ADAPTER_IFACE,
'Address')
@property
def powered(self):
"""
power state of the Adapter.
"""
return self.adapter_property.Get(self.ADAPTER_IFACE, 'Powered')
@powered.setter
def powered(self, new_state):
self.adapter_property.Set(self.ADAPTER_IFACE, 'Powered', new_state)
@property
def alias(self):
return self.adapter_property.Get(self.ADAPTER_IFACE,
'Alias')
@alias.setter
def alias(self, new_alias):
self.adapter_property.Set(self.ADAPTER_IFACE,
'Alias',
new_alias)
@property
def discoverabletimeout(self):
"""Discoverable timeout of the Adapter."""
return self.adapter_props.Get(self.ADAPTER_IFACE,
'DiscoverableTimeout')
@discoverabletimeout.setter
def discoverabletimeout(self, new_timeout):
self.adapter_property.Set(self.ADAPTER_IFACE,
'DiscoverableTimeout',
dbus.UInt32(new_timeout))
@property
def discoverable(self):
"""Discoverable state of the Adapter."""
return self.adapter_props.Get(
self.ADAPTER_INTERFACE, 'Discoverable')
@discoverable.setter
def discoverable(self, new_state):
self.adapter_property.Set(self.ADAPTER_IFACE,
'Discoverable',
new_state)
def config_hid_profile(self):
"""
Setup and register HID Profile
"""
print('Configuring Bluez Profile')
service_record = self.read_sdp_service_record()
opts = {
'Role': 'server',
'RequireAuthentication': False,
'RequireAuthorization': False,
'AutoConnect': True,
'ServiceRecord': service_record,
}
manager = dbus.Interface(self.bus.get_object('org.bluez',
'/org/bluez'),
'org.bluez.ProfileManager1')
HumanInterfaceDeviceProfile(self.bus,
BTKbDevice.PROFILE_DBUS_PATH)
manager.RegisterProfile(BTKbDevice.PROFILE_DBUS_PATH,
BTKbDevice.UUID,
opts)
print('Profile registered ')
@staticmethod
def read_sdp_service_record():
"""
Read and return SDP record from a file
:return: (string) SDP record
"""
print('Reading service record')
try:
fh = open(BTKbDevice.SDP_RECORD_PATH, 'r')
except OSError:
sys.exit('Could not open the sdp record. Exiting...')
return fh.read()
def listen(self):
"""
Listen for connections coming from HID client
"""
print('Waiting for connections')
self.scontrol = socket.socket(socket.AF_BLUETOOTH,
socket.SOCK_SEQPACKET,
socket.BTPROTO_L2CAP)
self.scontrol.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sinterrupt = socket.socket(socket.AF_BLUETOOTH,
socket.SOCK_SEQPACKET,
socket.BTPROTO_L2CAP)
self.sinterrupt.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.scontrol.bind((self.address, self.P_CTRL))
self.sinterrupt.bind((self.address, self.P_INTR))
# Start listening on the server sockets
self.scontrol.listen(1) # Limit of 1 connection
self.sinterrupt.listen(1)
self.ccontrol, cinfo = self.scontrol.accept()
print('{} connected on the control socket'.format(cinfo[0]))
self.cinterrupt, cinfo = self.sinterrupt.accept()
print('{} connected on the interrupt channel'.format(cinfo[0]))
def send(self, msg):
"""
Send HID message
:param msg: (bytes) HID packet to send
"""
self.cinterrupt.send(bytes(bytearray(msg)))
class BTKbService(dbus.service.Object):
"""
Setup of a D-Bus service to recieve HID messages from other
processes.
Send the recieved HID messages to the Bluetooth HID server to send
"""
def __init__(self):
print('Setting up service')
bus_name = dbus.service.BusName('org.yaptb.btkbservice',
bus=dbus.SystemBus())
dbus.service.Object.__init__(self, bus_name, '/org/yaptb/btkbservice')
# create and setup our device
self.device = BTKbDevice()
# start listening for socket connections
self.device.listen()
@dbus.service.method('org.yaptb.btkbservice',
in_signature='ay')
def send_keys(self, cmd):
self.device.send(cmd)
if __name__ == '__main__':
# The sockets require root permission
if not os.geteuid() == 0:
sys.exit('Only root can run this script')
DBusGMainLoop(set_as_default=True)
myservice = BTKbService()
mainloop = GLib.MainLoop()
mainloop.run()
import dbus
import evdev
import keymap
from time import sleep
HID_DBUS = 'org.yaptb.btkbservice'
HID_SRVC = '/org/yaptb/btkbservice'
class Kbrd:
"""
Take the events from a physically attached keyboard and send the
HID messages to the keyboard D-Bus server.
"""
def __init__(self):
self.target_length = 6
self.mod_keys = 0b00000000
self.pressed_keys = []
self.have_kb = False
self.dev = None
self.bus = dbus.SystemBus()
self.btkobject = self.bus.get_object(HID_DBUS,
HID_SRVC)
self.btk_service = dbus.Interface(self.btkobject,
HID_DBUS)
self.wait_for_keyboard()
def wait_for_keyboard(self, event_id=0):
"""
Connect to the input event file for the keyboard.
Can take a parameter of an integer that gets appended to the end of
/dev/input/event
:param event_id: Optional parameter if the keyboard is not event0
"""
while not self.have_kb:
try:
# try and get a keyboard - should always be event0 as
# we're only plugging one thing in
self.dev = evdev.InputDevice('/dev/input/event{}'.format(
event_id))
self.have_kb = True
except OSError:
print('Keyboard not found, waiting 3 seconds and retrying')
sleep(3)
print('found a keyboard')
def update_mod_keys(self, mod_key, value):
"""
Which modifier keys are active is stored in an 8 bit number.
Each bit represents a different key. This method takes which bit
and its new value as input
:param mod_key: The value of the bit to be updated with new value
:param value: Binary 1 or 0 depending if pressed or released
"""
bit_mask = 1 << (7-mod_key)
if value: # set bit
self.mod_keys |= bit_mask
else: # clear bit
self.mod_keys &= ~bit_mask
def update_keys(self, norm_key, value):
if value < 1:
self.pressed_keys.remove(norm_key)
elif norm_key not in self.pressed_keys:
self.pressed_keys.insert(0, norm_key)
len_delta = self.target_length - len(self.pressed_keys)
if len_delta < 0:
self.pressed_keys = self.pressed_keys[:len_delta]
elif len_delta > 0:
self.pressed_keys.extend([0] * len_delta)
@property
def state(self):
"""
property with the HID message to send for the current keys pressed
on the keyboards
:return: bytes of HID message
"""
return [0xA1, 0x01, self.mod_keys, 0, *self.pressed_keys]
def send_keys(self):
self.btk_service.send_keys(self.state)
def event_loop(self):
"""
Loop to check for keyboard events and send HID message
over D-Bus keyboard service when they happen
"""
print('Listening...')
for event in self.dev.read_loop():
# only bother if we hit a key and its an up or down event
if event.type == evdev.ecodes.EV_KEY and event.value < 2:
key_str = evdev.ecodes.KEY[event.code]
mod_key = keymap.modkey(key_str)
if mod_key > -1:
self.update_mod_keys(mod_key, event.value)
else:
self.update_keys(keymap.convert(key_str), event.value)
self.send_keys()
if __name__ == '__main__':
print('Setting up keyboard')
kb = Kbrd()
print('starting event loop')
kb.event_loop()
#
# www.linuxuser.co.uk/tutorials/emulate-a-bluetooth-keyboard-with-the-raspberry-pi
#
#
#
# Convert value returned from Linux event device ("evdev") to a HID code.
# This is reverse of what's actually hardcoded in the kernel.
#
# Lubomir Rintel <lkundrak@v3.sk>
# License: GPL
#
# Ported to a Python module by Liam Fraser.
#
keytable = {
"KEY_RESERVED": 0,
"KEY_ESC": 41,
"KEY_1": 30,
"KEY_2": 31,
"KEY_3": 32,
"KEY_4": 33,
"KEY_5": 34,
"KEY_6": 35,
"KEY_7": 36,
"KEY_8": 37,
"KEY_9": 38,
"KEY_0": 39,
"KEY_MINUS": 45,
"KEY_EQUAL": 46,
"KEY_BACKSPACE": 42,
"KEY_TAB": 43,
"KEY_Q": 20,
"KEY_W": 26,
"KEY_E": 8,
"KEY_R": 21,
"KEY_T": 23,
"KEY_Y": 28,
"KEY_U": 24,
"KEY_I": 12,
"KEY_O": 18,
"KEY_P": 19,
"KEY_LEFTBRACE": 47,
"KEY_RIGHTBRACE": 48,
"KEY_ENTER": 40,
"KEY_LEFTCTRL": 224,
"KEY_A": 4,
"KEY_S": 22,
"KEY_D": 7,
"KEY_F": 9,
"KEY_G": 10,
"KEY_H": 11,
"KEY_J": 13,
"KEY_K": 14,
"KEY_L": 15,
"KEY_SEMICOLON": 51,
"KEY_APOSTROPHE": 52,
"KEY_GRAVE": 53,
"KEY_LEFTSHIFT": 225,
"KEY_BACKSLASH": 50,
"KEY_Z": 29,
"KEY_X": 27,
"KEY_C": 6,
"KEY_V": 25,
"KEY_B": 5,
"KEY_N": 17,
"KEY_M": 16,
"KEY_COMMA": 54,
"KEY_DOT": 55,
"KEY_SLASH": 56,
"KEY_RIGHTSHIFT": 229,
"KEY_KPASTERISK": 85,
"KEY_LEFTALT": 226,
"KEY_SPACE": 44,
"KEY_CAPSLOCK": 57,
"KEY_F1": 58,
"KEY_F2": 59,
"KEY_F3": 60,
"KEY_F4": 61,
"KEY_F5": 62,
"KEY_F6": 63,
"KEY_F7": 64,
"KEY_F8": 65,
"KEY_F9": 66,
"KEY_F10": 67,
"KEY_NUMLOCK": 83,
"KEY_SCROLLLOCK": 71,
"KEY_KP7": 95,
"KEY_KP8": 96,
"KEY_KP9": 97,
"KEY_KPMINUS": 86,
"KEY_KP4": 92,
"KEY_KP5": 93,
"KEY_KP6": 94,
"KEY_KPPLUS": 87,
"KEY_KP1": 89,
"KEY_KP2": 90,
"KEY_KP3": 91,
"KEY_KP0": 98,
"KEY_KPDOT": 99,
"KEY_ZENKAKUHANKAKU": 148,
"KEY_102ND": 100,
"KEY_F11": 68,
"KEY_F12": 69,
"KEY_RO": 135,
"KEY_KATAKANA": 146,
"KEY_HIRAGANA": 147,
"KEY_HENKAN": 138,
"KEY_KATAKANAHIRAGANA": 136,
"KEY_MUHENKAN": 139,
"KEY_KPJPCOMMA": 140,
"KEY_KPENTER": 88,
"KEY_RIGHTCTRL": 228,
"KEY_KPSLASH": 84,
"KEY_SYSRQ": 70,
"KEY_RIGHTALT": 230,
"KEY_HOME": 74,
"KEY_UP": 82,
"KEY_PAGEUP": 75,
"KEY_LEFT": 80,
"KEY_RIGHT": 79,
"KEY_END": 77,
"KEY_DOWN": 81,
"KEY_PAGEDOWN": 78,
"KEY_INSERT": 73,
"KEY_DELETE": 76,
"KEY_MUTE": 239,
"KEY_VOLUMEDOWN": 238,
"KEY_VOLUMEUP": 237,
"KEY_POWER": 102,
"KEY_KPEQUAL": 103,
"KEY_PAUSE": 72,
"KEY_KPCOMMA": 133,
"KEY_HANGEUL": 144,
"KEY_HANJA": 145,
"KEY_YEN": 137,
"KEY_LEFTMETA": 227,
"KEY_RIGHTMETA": 231,
"KEY_COMPOSE": 101,
"KEY_STOP": 243,
"KEY_AGAIN": 121,
"KEY_PROPS": 118,
"KEY_UNDO": 122,
"KEY_FRONT": 119,
"KEY_COPY": 124,
"KEY_OPEN": 116,
"KEY_PASTE": 125,
"KEY_FIND": 244,
"KEY_CUT": 123,
"KEY_HELP": 117,
"KEY_CALC": 251,
"KEY_SLEEP": 248,
"KEY_WWW": 240,
"KEY_COFFEE": 249,
"KEY_BACK": 241,
"KEY_FORWARD": 242,
"KEY_EJECTCD": 236,
"KEY_NEXTSONG": 235,
"KEY_PLAYPAUSE": 232,
"KEY_PREVIOUSSONG": 234,
"KEY_STOPCD": 233,
"KEY_REFRESH": 250,
"KEY_EDIT": 247,
"KEY_SCROLLUP": 245,
"KEY_SCROLLDOWN": 246,
"KEY_F13": 104,
"KEY_F14": 105,
"KEY_F15": 106,
"KEY_F16": 107,
"KEY_F17": 108,
"KEY_F18": 109,
"KEY_F19": 110,
"KEY_F20": 111,
"KEY_F21": 112,
"KEY_F22": 113,
"KEY_F23": 114,
"KEY_F24": 115
}
# Map modifier keys to array element in the bit array
modkeys = {
"KEY_RIGHTMETA": 0,
"KEY_RIGHTALT": 1,
"KEY_RIGHTSHIFT": 2,
"KEY_RIGHTCTRL": 3,
"KEY_LEFTMETA": 4,
"KEY_LEFTALT": 5,
"KEY_LEFTSHIFT": 6,
"KEY_LEFTCTRL": 7
}
def convert(evdev_keycode):
return keytable[evdev_keycode]
def modkey(evdev_keycode):
if evdev_keycode in modkeys:
return modkeys[evdev_keycode]
else:
return -1 # Return an invalid array element
<!DOCTYPE busconfig PUBLIC
"-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
<policy context="default">
<allow own="org.yaptb.btkbservice"/>
<allow send_destination="org.yaptb.btkbservice"/>
</policy>
</busconfig>
<?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="Raspberry Pi Virtual Keyboard" />
</attribute>
<attribute id="0x0101">
<text value="USB > BT Keyboard" />
</attribute>
<attribute id="0x0102">
<text value="Raspberry Pi" />
</attribute>
<attribute id="0x0200">
<uint16 value="0x0100" />
</attribute>
<attribute id="0x0201">
<uint16 value="0x0111" />
</attribute>
<attribute id="0x0202">
<uint8 value="0x40" />
</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="05010906a101850175019508050719e029e715002501810295017508810395057501050819012905910295017503910395067508150026ff000507190029ff8100c0050c0901a1018503150025017501950b0a23020a21020ab10109b809b609cd09b509e209ea09e9093081029501750d8103c0" />
</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="true" />
</attribute>
<attribute id="0x020e">
<boolean value="false" />
</attribute>
<attribute id="0x020f">
<uint16 value="0x0640" />
</attribute>
<attribute id="0x0210">
<uint16 value="0x0320" />
</attribute>
</record>
@ukBaz
Copy link
Author

ukBaz commented Aug 31, 2020

Thanks @PulgaFeroz for taking a look at this.

I have a couple of questions about what you have done.

You have used the function BluetoothSocket which doesn't seem to be available. Have you defined this elsewhere? I have seen a function of this name when people have imported pyBluez. This should be an unnecessary dependency and definitely not one I would keen to use.

Well done for picking your way through the manual SDP record. However, I would ideally like to move away from having the very obfuscated manual SDP entry. The Profile API allows for SDP to be specified with the Version and Profile enters

I would prefer to move towards how it was done in the test-hfp example given in the BlueZ repository. The edited highlights are:

HF_3WAY			= 0x0002
HF_CLI			= 0x0004
HF_VOICE_RECOGNITION	= 0x0008
HF_REMOTE_VOL		= 0x0010
HF_ENHANCED_STATUS	= 0x0020
HF_ENHANCED_CONTROL	= 0x0040
HF_CODEC_NEGOTIATION	= 0x0080


HF_FEATURES = (HF_3WAY | HF_CLI | HF_VOICE_RECOGNITION |
			HF_REMOTE_VOL | HF_ENHANCED_STATUS |
			HF_ENHANCED_CONTROL | HF_CODEC_NEGOTIATION)

	opts = {
			"Version" : dbus.UInt16(0x0106),
			"Features" : dbus.UInt16(HF_FEATURES),
		}

manager.RegisterProfile(options.path, "hfp-hf", opts)

@PulgaFeroz
Copy link

PulgaFeroz commented Aug 31, 2020

Oh damn, you're right, I started working with the original code, then moved to your improved version and while crafting a solution I mixed up old and new code, so indeed BluetoothSocket came from pybluez

I'm not familiar with most of the involved libraries (neither bluetooth for that matter :D) so no idea at the moment what can replace the BluetoothSocket, I'll search around

BTW, I found also a linux command line tool for doing exactly this: https://linux.die.net/man/8/btkbdd and there seem to be others around, some extra info may lurk in that code.

@PulgaFeroz
Copy link

PulgaFeroz commented Aug 31, 2020

seems like a normal socket will do... looking at your code:

self.scontrol = socket.socket(socket.AF_BLUETOOTH,
                                      socket.SOCK_SEQPACKET,
                                      socket.BTPROTO_L2CAP)

The server socket is created with a standard socket, the client socket would not need to be different, didn't tried yet but replacing BluetoothSocket for socket.socket should work

(those were his last famous words) :)

@PulgaFeroz
Copy link

PulgaFeroz commented Aug 31, 2020

yup, I confirmed it:

    def reconnect(self, hidHost):
        print("Trying reconnect...")
        while True:
        try:
            # hidHost = 'XX:XX:XX:XX:XX:XX'    
            self.ccontrol = socket.socket(socket.AF_BLUETOOTH,
                                          socket.SOCK_SEQPACKET,
                                          socket.BTPROTO_L2CAP)
            self.cinterrupt = socket.socket(socket.AF_BLUETOOTH,
                                            socket.SOCK_SEQPACKET,
                                            socket.BTPROTO_L2CAP)
            self.ccontrol.connect((hidHost, self.P_CTRL))
            self.cinterrupt.connect((hidHost, self.P_INTR))
            print("Connected!")
        except Exception as ex:
            print("didnt connect, will retry..." + str(ex))
            time.sleep(1)

Works, sorry for the mixup :D

@ukBaz
Copy link
Author

ukBaz commented Aug 31, 2020

Well done @PulgaFeroz on testing that the standard Python sockets library can be used.

@ukBaz
Copy link
Author

ukBaz commented Aug 31, 2020

I've been investigating how to switch from using the manual SDP record, to using the other method mentioned in the Profile API.

I've looked at the specification for Bluetooth Human Interface Device Profile (HID) which is at:
https://www.bluetooth.com/specifications/profiles-overview/
There is section 5.3.4 Bluetooth HID SDP Attributes which has the attribute names, attribute IDs. Not sure how to set values without doing the full manual SDP description.

Section 5.4.2 Connectability also looks of interest.

@ukBaz
Copy link
Author

ukBaz commented Sep 1, 2020

From the Human Interface Device (HID) Profile , section 5.4.2 Connectability
image

Attribute name  attribute id Current setting @PulgaFeroz proposal
HIDVirtualCable 0x0204 FALSE FALSE
HIDReconnectInitiate 0x0205 FALSE TRUE
HIDNormallyConnectable 0x020D TRUE TRUE

My reading of the table is that ideally you would want all three values to be True.

From section 4.5.1 Virtual Cable Establishment:

If the HIDVirtualCable SDP attribute is set to TRUE, then a Virtual Cable is considered to be established after both the HID Control and HID Interrupt L2CAP channels have been opened.

With the HIDVirtualCable set to true, then I think the change made previously of adding the on_disconnect method should be enough as the listen method opens the sockets. This would remove the need for adding the reconnect method.

Is anyone able to test this with Apple hardware?

@PulgaFeroz
Copy link

PulgaFeroz commented Sep 1, 2020

From what I've read the 'virtual cable' has some special considerations, didn't go thru the full spec of it, I think that as far as the service wants to talk 1-1 was ok.

Of course different use cases needs different tweaks, in my case I built a password manager in a pi zero, I want to initiate the connection from the device (not the host, at least for now), so in my case I don't use the 'listen' method or start listening when a connection is closed

There's something weird about the need of using the computer physical mouse or keyboard in order to be able to connect the wireless keyboard isn't it :)

@PulgaFeroz
Copy link

PulgaFeroz commented Sep 1, 2020

Oh forgot to mention, there are a few rough corners to iron out, something I noticed is that if the (python) service is running and I open 'sudo bluetoothctl' then the service throws errors.

ERROR:dbus.connection:Exception in handler for D-Bus signal:
Traceback (most recent call last):
  File "/usr/lib/python3/dist-packages/dbus/connection.py", line 230, in maybe_handle_message
    self._handler(*args, **kwargs)
TypeError: interfaces_added() takes 1 positional argument but 3 were given

I also observed a few times (I think right after pairing) the host connects and gets disconnected immediately, it gets into a funky state, stopping and restarting the bt service + python service and things go back to normal, I saw this happening at least twice

@ukBaz
Copy link
Author

ukBaz commented Sep 2, 2020

The error you are seeing will be because this:

    def interfaces_added(self):
        pass

Needs to be changed to this:

    def interfaces_added(self, path, device_info):
        pass

This method gets called if a new device is discovered

@neo774
Copy link

neo774 commented Feb 21, 2021

Hey ukBaz,
thank you for the work you put into it! I am looking for such programm and tried to run it under a Linuxmint setup. I get to the point where the programm is waiting for a connection but when I try to pair my phone with the emulated keyboard I get the message that the keyboard rejected pairing. Do you have any Idea how to solve this?

@ukBaz
Copy link
Author

ukBaz commented Feb 21, 2021

It is difficult to know what this issue is from the information you have given. It is usually helpful to have a second terminal window open with bluetoothctl running and the default agent selected. Hopefully that will give you some more information.

@neo774
Copy link

neo774 commented Feb 22, 2021

Hey, thank you for your quick repsonse. I tried what you suggest and get a bit more insights.
I got to the point where on both devices code numbers where displayed. When I accepted the control socket and interrupt channel were connected but directly afterwards a dbus error ocurred: interface_added() takes 1 positional argument but 3 were given. On the same time in the bluetoothctl terminal I was asked to proceed with service authorization. Could the problem occur due to this further authorization requests in sense paring process is not yet completed, but the python script already proceeds?

@ukBaz
Copy link
Author

ukBaz commented Feb 23, 2021

Have tried the solution in the comment above your original comment?
https://gist.github.com/ukBaz/a47e71e7b87fbc851b27cde7d1c0fcf0#gistcomment-3439321

@neo774
Copy link

neo774 commented Feb 23, 2021

No, but know I tried and I was able to establish a connection - thx a lot for your help! (I didn't check the lines above agein...) Now, I got some problems with the keyboardclient. Analysed it and somehow the evdev module is not working probably in my setup - the read_loop function is not recording keyboard presses (I checked with evtest and set the eventfile number properly). Could this be due to kernel interferences? I checked with fuser that there root processes running on the corresponding event file.

@neo774
Copy link

neo774 commented Feb 24, 2021

Okay, I found out that there is more then one eventfile corresponding to the controller. When I adjusted it the client proceeded till the send_keys() line after pushing a button. But unfortunately I got no no reaction on the connected smartphone and shortly after I got the following error:

dbus.proxies:Introspect error on :1.2589:/org/yaptb/btkbservice: dbus.exceptions.DBusException: org.freedesktop.DBus.Error.NoReply: 
Did not receive a reply. Possible causes include: 
    the remote application did not send a reply, 
    the message bus security policy blocked the reply,
    the reply timeout expired, or the network connection was broken.

@ukBaz
Copy link
Author

ukBaz commented Feb 25, 2021

@neo774, you are not really giving me enough information to help much with this. Are you running something like sudo busctl monitor org.yaptb.btkkbservice to see what is happening on the D-Bus?
If I was to speculate, of the three items the error suggests as possible causes, the security policy not being set is the most likely as that is one of the set-up set-up described above. Did you do the sudo cp org.yaptb.btkkbservice.conf /etc/dbus-1/system.d step?

@neo774
Copy link

neo774 commented Feb 25, 2021

Hey, thank you for your patience in trying to help me!
Now it, works though I didn't change anything. (I did the setup you asked for as a first step before even trying the server script the first time)
I just wanted to run the scripts with busctl monitor on as you suggested and it worked straight away without any errors occurring ...
I can't explain why, but anyway - seems solved.

One little thing: when connecting in the bluetoothctl monitor I always get asked to authorize services (from the connected device I guess) and have to type in yes two times manually - can I somehow automate that confirmation?

[M1;39m[agent] Authorize service 00001108-0000-1000-8000-00805f9b34fb (yes/no): yes
Authorize service
[M1;39m[agent] Authorize service 0000110d-0000-1000-8000-00805f9b34fb (yes/no): yes

@ukBaz
Copy link
Author

ukBaz commented Feb 25, 2021

@neo774, you haven't said what OS your phone is running. There are various discussions in the comments above about this topic. I would have thought adding your device as a trusted device would resolve the need to authorize the service each time.

There is always a degree of bit rot over time with any unmaintained software. This gist was a response to the bit rot of the original article. Maybe this gist has outlived its usefulness. It is not something I continue to use so sits here unmaintained. There are some people that have brought great knowledge to this topic and as a result, I am reluctant to delete the record this gist has created as it might offer some insight to someone.

@neo774
Copy link

neo774 commented Feb 25, 2021

@ukBaz, sure I understand. I didn't want to bother you (just didn't know who else to ask). In fact this project is totally what I was looking for and certainly it is useful for others as well - there is actually not so much software out there addressing this functionality. Therefore thx a lot!
(The OS is Android 11; I will check the comments above to see how to add it as a trusted device)

@ukBaz
Copy link
Author

ukBaz commented Apr 3, 2021

@HeadHodge has created an example that uses the newer HID over GATT Profile (HOGP) and thought it might be a useful reference for people:

https://github.com/HeadHodge/Bluez-HID-over-GATT-Keyboard-Emulator-Example

@ackerleytng
Copy link

ackerleytng commented Sep 28, 2021

I'm trying to follow this guide to build a bluetooth "keyboard" that is able to send a few keystrokes, such as the equivalent of alt-tab, to my Android phone.

So far, I've been able to

  • Register a profile through org.bluez.ProfileManager1.RegisterProfile with an sdp_record.xml that I'm not sure is completely correct
  • Register a service through org.bluez.Profile1 that exports a few methods

I would start the program on my laptop (linux), which does the above 2 registrations, and then use bluetoothctl to manually do discoverable on, and then get my Android phone to pair with the laptop. Pairing is successful, but the service, which listens on ports 0x11 and 0x13, never seem to receive any connections. accept() is never called.

Is my overall understanding right? The laptop, acting as the bluetooth keyboard, will serve a profile and a service (SDP), that the phone will read. When it tries to pair with the laptop, it will read the ports listed in the profile and attempt to connect to those ports?

Any ideas on where I can start debugging?

@ukBaz
Copy link
Author

ukBaz commented Sep 28, 2021

The general advice I give to get Bluetooth debug information on Linux is the following.

When running your script have separate terminals open with the following running to get more debug information:

  • bluetootctl
  • journalctl -f -u bluetooth
  • sudo busctl monitor org.bluez
  • sudo btmon

Your general understanding sounds correct but the trouble is always in the detail. You don't mention about re-configuring the bluetooth daemon which is a very important step. Does your laptop show up as a keyboard after you have paired the two devices?

@ackerleytng
Copy link

ackerleytng commented Sep 28, 2021

Thank you soooo much! You hit the nail on the head!

I'm trying to reproduce this in golang.

When binding, I initially didn't specify the address to bind to. Binding succeeded, but I'm also not sure which interface it was binding to, and there was no EADDRINUSE error. If I read and use the org.bluez.Adapter1 Address read from /org/bluez/hci0, then I start getting the EADDRINUSE error.

I definitely needed to reconfigure the bluetooth daemon to remove the default input plugin by stopping bluetoothd and then restarting it as follows

sudo /usr/lib/bluetooth/bluetoothd --noplugin input

(--noplugin is the same as -P documented above)

Thanks once again!

@TehseenHasan
Copy link

TehseenHasan commented Oct 5, 2021

I followed the procedure and Now My PC is connected to the Bluetooth of the Raspberry Pi Zero W. Terminal 1 is showing this :

>>> %cd /home/pi/Desktop/BluetoothHID
>>> %Run btk_server.py
Setting up service
Setting up BT device
Configuring for name BT_HID_Keyboard
Configuring Bluez Profile
Reading service record
Profile registered 
Waiting for connections
D8:0F:99:73:BC:14 connected on the control socket
D8:0F:99:73:BC:14 connected on the interrupt channel

But when I run the kb_client.py it is giving this error:

sudo python3 kb_client.py
Setting up keyboard
Keyboard not found, waiting 3 seconds and retrying
found a keyboard
Keyboard not found, waiting 3 seconds and retrying
found a keyboard
Keyboard not found, waiting 3 seconds and retrying
found a keyboard
Keyboard not found, waiting 3 seconds and retrying

and So on......

I am using my Raspberry Pi through VNC Server and there are no Physical Keyboard or Mouse is attached to the Rasberry Pi USB port.

I want to send the text data from my Custom python Code to the PC over Bluetooth HID. It seems that in your kb_client.py code you are trying to detect a physical keyboard attached to the RPi and then sending its keystrokes over the Bluetooth.

But I just need to send a custom string from my python code, not from the keyboard. Please guide me on how can I do this. Thanks a lot!

UPDATE!

for testing, I just connected a USB keyboard to my Raspberry Pi Zero W and then run sudo python3 kb.client.py and now It is working perfectly and I am receiving the keystrokes from the Raspberry Pi's keyboard to my PC over Bluetooth HID.

Now my only question is that how can I send the custom string data through my python code instead of using a physical keyboard.?
Thanks!

@ukBaz
Copy link
Author

ukBaz commented Oct 5, 2021

@TehseenHasan, this kind of question has been asked further up in this gist. I think it starts about here https://gist.github.com/ukBaz/a47e71e7b87fbc851b27cde7d1c0fcf0#gistcomment-2996833

@joyobooto
Copy link

joyobooto commented Oct 8, 2021

Is there a plan to get mouse support working? I've found a similar project https://github.com/quangthanh010290/keyboard_mouse_emulate_on_raspberry/blob/master/mouse/mouse_client.py which I'm trying to migrate over but my knowledge over the libraries used is limiting me a little...

@HeadHodge
Copy link

HeadHodge commented Oct 8, 2021

you can use your existing code by modifying your sdp file to add a second usb hid report descriptor for a mouse.

it takes effort, but this tool helps: https://eleccelerator.com/usbdescreqparser/

once the report descriptor is modified, you send reports for either the keyboard or mouse by specifying the correct report id in your reports.

Here is an example of a report descriptor i made, that works well, that has 3 reports for a keyboard, mouse and consumer device.

//Report Reference Id = 1 (128 key Keyboard)
//Modifier Byte: bit 0 is L CTRL, bit 1 is L SHIFT, bit 2 is L ALT, bit 3 is L GUI, bit 4 is R CTRL, bit 5 is R SHIFT, bit 6 is R ALT, and bit 7 is R GUI
0x05, 0x01, 	  // Usage Page (Generic Desktop Ctrls)
0x09, 0x06, 	  // Usage (Keyboard)
0xa1, 0x01, 	  // Collection (Application)
0x85, 0x01, 	  //   Report ID (1)
0x05, 0x07, 	  //   Usage Page (Kbrd/Keypad)
0x19, 0xe0, 	  //   Usage Minimum (0xE0)
0x29, 0xe7, 	  //   Usage Maximum (0xE7)
0x15, 0x00, 	  //   Logical Minimum (0)
0x25, 0x01, 	  //   Logical Maximum (1)
0x75, 0x01, 	  //   Report Size (1)
0x95, 0x08, 	  //   Report Count (8)
0x81, 0x02, 	  //   Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
0x95, 0x01, 	  //   Report Count (1)
0x75, 0x08, 	  //   Report Size (8)
0x15, 0x00, 	  //   Logical Minimum (0)
0x25, 0x65, 	  //   Logical Maximum (101)
0x05, 0x07, 	  //   Usage Page (Kbrd/Keypad)
0x19, 0x00, 	  //   Usage Minimum (0x00)
0x29, 0x65, 	  //   Usage Maximum (0x65)
0x81, 0x00, 	  //   Input (Data,Array,Abs,No Wrap,Linear,Preferred State,No Null Position)
0xc0,			  // End Collection

//Report Reference Id = 2 (Consumer Entertainment Control)
0x05, 0x0C, 	  // Usage Page (Consumer)
0x09, 0x01, 	  // Usage (Consumer Control)
0xA1, 0x01, 	  // Collection (Application)
0x85, 0x02, 	  //   Report ID (2)
0x75, 0x10, 	  //   Report Size (16)
0x95, 0x01, 	  //   Report Count (1)
0x15, 0x01,		  //   Logical Minimum (1)
0x26, 0xff, 0x07, //   Logical Maximum (2047)
0x19, 0x01, 	  //   Usage Minimum (Consumer Control)
0x2A, 0xff, 0x07, //   Usage Maximum (0x07FF)
0x81, 0x00, 	  //   Input (Data,Array,Abs,No Wrap,Linear,Preferred State,No Null Position)
0xC0, 	  	  	  // End Collection

//Report Reference Id = 3 (Generic Mouse)
0x05, 0x01,       // Usage Page (Generic Desktop Ctrls)
0x09, 0x02,       // Usage (Mouse)
0xA1, 0x01,       // Collection (Application)
0x85, 0x03, 	  //   Report ID (3)
0x09, 0x01,       //   Usage (Pointer)
0xA1, 0x00,       //   Collection (Physical)
0x05, 0x09,       //     Usage Page (Button)
0x19, 0x01,       //     Usage Minimum (0x01)
0x29, 0x03,       //     Usage Maximum (0x03)
0x15, 0x00,       //     Logical Minimum (0)
0x25, 0x01,       //     Logical Maximum (1)
0x95, 0x03,       //     Report Count (3)
0x75, 0x01,       //     Report Size (1)
0x81, 0x02,       //     Input (Data,Var,Abs,No Wrap,Linear,...)
0x95, 0x01,       //     Report Count (1)
0x75, 0x05,       //     Report Size (5)
0x81, 0x03,       //     Input (Const,Var,Abs,No Wrap,Linear,...)
0x05, 0x01,       //     Usage Page (Generic Desktop Ctrls)
0x09, 0x30,       //     Usage (X)
0x09, 0x31,       //     Usage (Y)
0x15, 0x81,       //     Logical Minimum (-127)
0x25, 0x7F,       //     Logical Maximum (127)
0x75, 0x08,       //     Report Size (8)
0x95, 0x02,       //     Report Count (2)
0x81, 0x06,       //     Input (Data,Var,Rel,No Wrap,Linear,...)
0xC0,             //   End Collection
0xC0,             // End Collection

@joyobooto
Copy link

joyobooto commented Oct 11, 2021

Thanks, I've made a little progress (just getting mouse buttons working). Wondering if you have a code sample on how you sent using your report descriptor? My modifications for the mouse client detect buttons correctly but when I send button info, it triggers media keys for reasons I do not know yet...

@HeadHodge
Copy link

HeadHodge commented Oct 11, 2021

hi,

i don't use bt 3.0 classic any longer, just bt 4.2 or bt 5.0, so don't have any good code snips for you.

looking at report 3 (mouse) above, the report code boils down to a report of 3 bytes.

byte 1: uses 1st 3 bits of the byte to determine button presses. 0-no buttons, 1-left button, 2-right button, 3-center button. the remaining bits of the byte are ignored.

byte 2: X-axis cursor relative offset movement from current cursor location.

byte 3: Y-axis cursor relative offset movement from current cursor location.

this is from memory but example use something like:

    elif(reportNum == 3):    
        #Send Report #3
        print('SEND Report 3')
        #Send Report #3
        await loop.sock_sendall(options['connection'], bytes([ 0xA1, reportNum, buttons, x-axis, y-axis ]))
        await asyncio.sleep(hold)  
        await loop.sock_sendall(options['connection'], bytes([ 0xA1, reportNum, 0, 0, 0 ]))
        return        

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment