Skip to content

Instantly share code, notes, and snippets.

@ukBaz

ukBaz/README.md Secret

Last active December 8, 2025 08:51
Show Gist options
  • Select an option

  • Save ukBaz/a47e71e7b87fbc851b27cde7d1c0fcf0 to your computer and use it in GitHub Desktop.

Select an option

Save ukBaz/a47e71e7b87fbc851b27cde7d1c0fcf0 to your computer and use it in GitHub Desktop.
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

Note [Apr-2024]: The above link is now dead but it still available on the Wayback Machine archive:
https://web.archive.org/web/20181117162450/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.btkbservice.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>
@ByteMe666
Copy link

ByteMe666 commented Feb 16, 2024

Hi @ukBaz ,
I've just made efforts recently going in the same direction as you, and as I started out I had found various older projects, which I lost track of actually. The oldest one was 9 years old, and it seems every 3 years someone updated it. Your blog post was helpful and the most recent, so I figured I'd reach out and tell you about my findings.

The main finding I have is I managed to get NewConnection to trigger while pairing using only the BlueZ DBus API methods.

My scenario is that I am trying to send specific bluetooth keyboard strokes to my Windows Laptop from my Raspberry Pi. The reason is I wish to programmatically trigger shortcuts wirelessly on my Windows machine, to pop up or hide menus, for reasons too long and irrelevant to detail. The point is I am not trying to emulate an entire keyboard, just some sequences of key presses.

Similarly to you, I found a working solution that resembles what you've documented here, but, I was frustrated I could not initiate a reconnection from the keyboard emulating device (Pi) after the connection was lost. I also felt using sockets and messing with the bluetooth.service was cheating. So, I tried to get it to work using strictly the BlueZ DBus API.

My code implementation is very straightforward and by the book, and I also commentate on important caveats I've circumvented:

  1. A HumanInterfaceDeviceProfile class identical to yours, with keymap.py and sdp_record.xml files identical.
  2. An Agent class implementing required methods which are described in bluez/doc/org.bluez.Agent.rst in the BlueZ source code.
    The implementation is very basic and handles connection attempts while expecting the user to interact with the CLI to type the PIN or write "yes" to authorize a request.
  3. The AgentManager is used to register the aforementioned manager, and the RequestDefaultAgent() method is called to ensure that my agent is used to handle connection attempts with the CLI methods I defined.
    It is important to note that my agent was ignored and not handling connection attempts when I defined, and registered my agent, and then attempted to pair to a device in different sub-methods of a BluetoothFakeKeyboard class I had created merely to compartimentalize my code. Apparently whatever automatic mechanism behind the scenes that makes BlueZ know what agent to use to handle a connection attempt gets deeply confused if you try to split up your code in methods. I have not cared to figure out what exactly went wrong and just unwrapped any code I had so it executed directly in the top most level of my script.
  4. The last critical step was the Profile Registration, the opts dictionary needed an important extra entry:
    "PSM" : dbus.UInt16(17)
    I identified this issue because btmon logs after successful pairing showed that when my laptop tried to use the advertised keyboard service from my Raspberry Pi, it tried to use PSM 17 but was denied because PSM 17 was not supported.
  5. After adding this entry to the dictionary, I set my adapter to discoverable and my NewConnection method from my Agent triggered when I initiated pairing from my laptop.

And this is as far as I got unfortunately. Now my code fails because my laptop tries to connect to PSM 19 for the interrupt channel, but it also gets denied. Unfortunately, poking around the source code of BlueZ I found the file bluez/src/profile.c which has a parse_ext_op() function which seems to indicate that it is pointless to add "PSM" : dbus.UInt16(19) to the dictionary as that would overwrite the "PSM" : dbus.UInt16(17) entry.

Tomorrow I would try to deal with PSM 19 separately using sockets, effectively loosening my restriction of using exclusively the DBus API. My code structure would attempt to track the paired and or connected status using the BlueZ DBus API, and always ensure separately that a socket is always available to deal with PSM 19 separately. Hopefully PSM 17 would be handled by BlueZ as a control connection for the service. I haven't put a lot of thought into it as I write this, but my initial idea would be to make the PSM 19 socket available in the NewConnection method, and reset it when the connection is lost or dropped.

Hopefully I could still manage to complete the program without resorting to tinkering with bluetooth.service. I would use identical keystroke sending methods to yours to write to the file descriptor NewConnection provides for me. The end result would not be as clean as I'd want it to be, but it would be an improvement.

All the best.

@ByteMe666
Copy link

Update on my previous post: it worked.

I setup PSM 19 with sinterrupt = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET,socket.BTPROTO_L2CAP) in the init function of my BlueZ Profile, and listen() at the end of the init function. When NewConnection is triggered I ran sinterrupt.accept(), and the connection is successful. This was for pairing initiated by the Windows laptop. For whatever reason, pairing has never worked when initiated by the Pi. It may be linked to what follows:

I wanted to reconnect after a disconnection. It makes sense to me the Raspberry Pi would initiate the reconnection. Even though the devices were paired and knew each other, the device.Connect() did not work. Capturing with btmon showed inexplicably that my Raspberry Pi attempted to connect to PSM 25 and was denied. Reading the documentation indicated that the Connect() method makes the Raspberry Pi attempt to connect to a service advertised by my Windows laptop, which is not what I wanted, but may explain why attempts were made to connect to PSM 25, but I did not bother to check what service my Windows laptop advertised there. Out of desperation I just created a PSM 17 scontrol socket just like in your code and tried to scrontrol.connect(): funnily enough I got an error message saying it was rejected, immediately followed by a NewConnection message from my BlueZ Profile and a successful reconnection.

As the code stands:

  • I do not fool around with bluetooth.service in any way (no editing the service file, no stopping the service etc)
  • I need to use sockets to handle the PSM 19 interrupt channel for incoming connections and the reconnexion
  • Basic keyboard connection behavior is achieved:
    a. Allow pairing from the laptop wanting to use the keyboard
    b. When pairing occurs, you may simply type the PIN code in the terminal and hit return
    c. Allow reconnection from the keyboard when it detects the paired laptop is present.

With the current version of the code, if I hide under the rug the "PSM 17 poke" error for reconnection, the code will look and behave like an actual bluetooth keyboard.

@jmkim
Copy link

jmkim commented Feb 20, 2024

Hi, I cloned this gist and followed your README.md, but could not pair the device. The device (BT_HID_Keyboard) appeared on my iPad but not appears on my laptop.

Here is my sudo btmon : https://paste.debian.net/1307899/

Could you please help me for figuring out the issue?

bluetoothctl -v :

bluetoothctl: 5.66

Terminal 1 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

Terminal 2 sudo python3 kb_client.py :

Setting up keyboard
found a keyboard
starting event loop
Listening...

@yesimxev
Copy link

Did this not work for you? bluetoothctl --agent NoInputNoOutput in another terminal. It will auto accept connections even if it wanted yes/no

@ByteMe666
Copy link

Update on my previous post: it worked.

I setup PSM 19 with sinterrupt = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET,socket.BTPROTO_L2CAP) in the init function of my BlueZ Profile, and listen() at the end of the init function. When NewConnection is triggered I ran sinterrupt.accept(), and the connection is successful. This was for pairing initiated by the Windows laptop. For whatever reason, pairing has never worked when initiated by the Pi. It may be linked to what follows:

I wanted to reconnect after a disconnection. It makes sense to me the Raspberry Pi would initiate the reconnection. Even though the devices were paired and knew each other, the device.Connect() did not work. Capturing with btmon showed inexplicably that my Raspberry Pi attempted to connect to PSM 25 and was denied. Reading the documentation indicated that the Connect() method makes the Raspberry Pi attempt to connect to a service advertised by my Windows laptop, which is not what I wanted, but may explain why attempts were made to connect to PSM 25, but I did not bother to check what service my Windows laptop advertised there. Out of desperation I just created a PSM 17 scontrol socket just like in your code and tried to scrontrol.connect(): funnily enough I got an error message saying it was rejected, immediately followed by a NewConnection message from my BlueZ Profile and a successful reconnection.

As the code stands:

  • I do not fool around with bluetooth.service in any way (no editing the service file, no stopping the service etc)
  • I need to use sockets to handle the PSM 19 interrupt channel for incoming connections and the reconnexion
  • Basic keyboard connection behavior is achieved:
    a. Allow pairing from the laptop wanting to use the keyboard
    b. When pairing occurs, you may simply type the PIN code in the terminal and hit return
    c. Allow reconnection from the keyboard when it detects the paired laptop is present.

With the current version of the code, if I hide under the rug the "PSM 17 poke" error for reconnection, the code will look and behave like an actual bluetooth keyboard.

I want to give the final word on my attempt.

  1. First I want to roll back one thing I said which isn't true: you need to add -P input to bluetoothd.service if you want the setup to work.
  2. I can be paired by, and I can reconnect to the host if the Raspberry Pi bluetooth keyboard disconnects, however the reverse is not true: if the host turns off their bluetooth and then reappear, the reconnection always fails and despite my best efforts to close the file descriptor and PSM 19 channel, I never successfully get a new connection from the host.

This latter point is a show stopper for me, my intended usecase is that the keyboard is always online and that the host drops out occasionally to power off and reappear at a later point in time. As I cannot reestablish the connection in this scenario, I'm giving up on doing a bluetooth keyboard emulation with BlueZ and the Raspberry Pi. I will try again with an ESP32 and the libraries it offers.

@jmkim
Copy link

jmkim commented Feb 26, 2024

@yesimxev your way worked for pairing. Thanks o/ Can I know the meaning of this command?

@yesimxev
Copy link

This command will act like a bluetooth device without screen or keyboard (like a headset where you can't input pairing code).

@jmkim
Copy link

jmkim commented Jun 25, 2024

Hey hey o/

Some devices have an error when I send the keyboard traffic:

ERROR:root:[Errno 107] Transport endpoint is not connected
ERROR:root:[Errno 107] Transport endpoint is not connected
ERROR:root:[Errno 107] Transport endpoint is not connected

Pairing works well, however it occurs when I click the button with kb_client.py.

btmon log during the pairing and the keyboard traffic:

I am having this error for 4 months, however couldn't fix. Any clues?

Many thanks!

@JasonEleventeen
Copy link

Seems like it should be possible to get the autoconnet working looking at this
https://github.com/tmckay1/pi_bluetooth_auto_connect/blob/main/auto-agent.py

I'm just not sure how to merge the 2

@ukBaz
Copy link
Author

ukBaz commented Oct 17, 2024

@jmkim Try using Wireshark to analyse the btmon log.

@ukBaz
Copy link
Author

ukBaz commented Oct 17, 2024

@JasonEleventeen Do you need to merge them? Can you not run the auto-agent.py in parallel. Both scripts are communicating with the bluetoothd. There are already many scripts communicating with bluetoothd

@Taxello
Copy link

Taxello commented Nov 7, 2024

I'm getting an error similar to this:

mustafa@raspberrypi:~/Desktop/proje $ 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
BC:2D:EF:37:AE:C6 connected on the control socket
BC:2D:EF:37:AE:C6 connected on the interrupt channel
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 218, in maybe_handle_message
    self._handler(*args, **kwargs)
TypeError: BTKbDevice.interfaces_added() takes 1 positional argument but 3 were given
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 218, in maybe_handle_message
    self._handler(*args, **kwargs)
TypeError: BTKbDevice.interfaces_added() takes 1 positional argument but 3 were given
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 218, in maybe_handle_message
    self._handler(*args, **kwargs)
TypeError: BTKbDevice.interfaces_added() takes 1 positional argument but 3 were given
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 218, in maybe_handle_message
    self._handler(*args, **kwargs)
TypeError: BTKbDevice.interfaces_added() takes 1 positional argument but 3 were given
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 218, in maybe_handle_message
    self._handler(*args, **kwargs)
TypeError: BTKbDevice.interfaces_added() takes 1 positional argument but 3 were given
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 218, in maybe_handle_message
    self._handler(*args, **kwargs)
TypeError: BTKbDevice.interfaces_added() takes 1 positional argument but 3 were given
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 218, in maybe_handle_message
    self._handler(*args, **kwargs)
TypeError: BTKbDevice.interfaces_added() takes 1 positional argument but 3 were given
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 218, in maybe_handle_message
    self._handler(*args, **kwargs)
TypeError: BTKbDevice.interfaces_added() takes 1 positional argument but 3 were given
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 218, in maybe_handle_message
    self._handler(*args, **kwargs)
TypeError: BTKbDevice.interfaces_added() takes 1 positional argument but 3 were given
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 218, in maybe_handle_message
    self._handler(*args, **kwargs)
TypeError: BTKbDevice.interfaces_added() takes 1 positional argument but 3 were given
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 218, in maybe_handle_message
    self._handler(*args, **kwargs)
TypeError: BTKbDevice.interfaces_added() takes 1 positional argument but 3 were given
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 218, in maybe_handle_message
    self._handler(*args, **kwargs)
TypeError: BTKbDevice.interfaces_added() takes 1 positional argument but 3 were given
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 218, in maybe_handle_message
    self._handler(*args, **kwargs)
TypeError: BTKbDevice.interfaces_added() takes 1 positional argument but 3 were given           

Can you please help?``

@Taxello
Copy link

Taxello commented Nov 7, 2024

And is it normal for the Raspbery Pi to appear as an audio device in Bluetooth? @ukBaz

@ukBaz
Copy link
Author

ukBaz commented Nov 7, 2024

@Taxello It would seem from the error message that def BTKbDevice.interfaces_added(self) should be BTKbDevice.interfaces_added(self, *args, **kwargs). It is not clear from the error message why you are the first to see this error.

Yes it is "normal" for the Raspberry Pi to appear as an audio device. If you do bluetoothctl show you will see by default it advertises audio services in the UUIDs. It is possible to change the appearance of the RPi if that is important to you.

@Taxello
Copy link

Taxello commented Nov 8, 2024

@ukBaz It worked, thank you very much

@Carter03
Copy link

When running btk_server.py, i get the error:
Connection on ":1.19" is not allowed to own the service "org.yaptb.btkbservice" due to security policies in the configuration file.

however, og.yaptb.btkbservice.conf is in /etc/dbus-1/system.d

Any help is appreciated :)

@ukBaz
Copy link
Author

ukBaz commented Dec 28, 2024

@Carter03 you have said og.yaptb.btkbservice.conf rather than org.yaptb.btkbservice.conf. Is that a typo? Other than that, I don't have any insight. If you search /etc/dbus-1/system.d through the comments of this gist there are other people that have debugged issues around this. Maybe one of those will help. Otherwise I think more debug information is required.

@Carter03
Copy link

Carter03 commented Dec 28, 2024

sorry, turns out i incorrectly copied the conf file... I'm very new to linux and RPi.

@Carter03
Copy link

@ukBaz is there an easy way to convert this to a mouse HID?
I'm not sure of the correct HID report format for mouse, or if it's even possible with this setup

thanks :)

@ukBaz
Copy link
Author

ukBaz commented Dec 29, 2024

@Carter03 This topic has been covered in the gist previously by far more knowledgeable people.

@ccatkinso
Copy link

ccatkinso commented Jan 30, 2025

Firstly thank you for documenting this! Secondly.... Damn I am hoping you can help me! As I have been trying to get BT to work as a HID device for weeks now :( Having followed all instructions for the keyboard. I am stuck here....

Running on an RPI5:

Setting up service
Setting up BT device
Configuring for name BT_HID_Keyboard
Configuring Bluez Profile
Reading service record
Profile registered
Waiting for connections

Everything seems ok, no errors, but the my device is not appearing as an available device to pair with. Any tips? I don't really know even where to look for errors?

@ukBaz
Copy link
Author

ukBaz commented Jan 30, 2025

but the my device is not appearing as an available device to pair with

Where is it not appearing?
Remember this is creating a Bluetooth Classic HID, not a BLE HID device. So if you are looking for a BLE device it will not appear.

@ccatkinso
Copy link

ccatkinso commented Jan 30, 2025

So with some tweaking I got this working.

Can I suggest the following changes which will fix a few issues on latest version of raspberry Pi:

Setup the system dependencies

  1. Update System packages with:
sudo apt-get update
sudo apt-get upgrade
sudo apt-get dist-upgrade
  1. Install the python dbus packages:
sudo apt-get install python3-dbus
  1. Install evdev with apt to avoid breaking system dependencies
sudo apt update
sudo apt install python3-evdev

Acquire files, test the config

  1. Clone the rep to the /root/btk_service folder with:
git clone https://gist.github.com/a47e71e7b87fbc851b27cde7d1c0fcf0.git /root/btk_service

Setting up the bluetooth server

  1. Disable the bluetooth service (it will restart if we don't do this first) and stop the service
sudo systemctl disable bluetooth
sudo service bluetooth stop
  1. Alter the line in /lib/systemd/system/bluetooth.service from

    ExecStart=/usr/libexec/bluetooth/bluetoothd

To

```ini
ExecStart=/usr/libexec/bluetooth/bluetoothd -P input
```
  1. Reboot for changes to take effect
sudo reboot
  1. Change the code in Changed code in btk_server.py on line 115 to include additional arguments
def interfaces_added(self,*args, **kwargs):  
  1. Test the server is working by running the server python program
sudo btk_server.py

Configuring the server as a service

  1. Create /etc/systemd/system/btk_server.service file and add these lines
[Unit]
Description=Bluetooth Keyboard Server
After=network.target bluetooth.target
Wants=bluetooth.service

[Service]
ExecStart=/usr/bin/python3 /root/btk_service/btk_server.py
WorkingDirectory=/root/btk_service/
Restart=always
RestartSec=5
User=root
Group=root
StandardOutput=append:/var/log/btk_server.log
StandardError=append:/var/log/btk_server.err

[Install]
WantedBy=multi-user.target
  1. Enable and start the service
sudo systemctl daemon-reload
sudo systemctl enable btk_server
sudo systemctl start btk_server

Create a test application which send defined keystrokes

The intent is to be able to send specific keystrokes on events. You can decide how to plug that in. But here is how to send the keystrokes programmatically

  1. Create kb_send.py
import dbus

HID_DBUS = 'org.yaptb.btkbservice'
HID_SRVC = '/org/yaptb/btkbservice'

class BluetoothSender:
    """
    A class to send a given string as HID messages over Bluetooth via D-Bus.
    """
    def __init__(self):
        self.bus = dbus.SystemBus()
        self.btkobject = self.bus.get_object(HID_DBUS, HID_SRVC)
        self.btk_service = dbus.Interface(self.btkobject, HID_DBUS)
    
    def send_string(self, text: str):
        """
        Sends a given string as a HID message over Bluetooth.
        :param text: The string to send
        """
        for char in text:
            hid_code = self.char_to_hid(char)
            if hid_code:
                # Send key press
                self.btk_service.send_keys([0xA1, 0x01, 0, 0, hid_code, 0, 0, 0, 0, 0])
                # Send key release (all zeros except header)
                self.btk_service.send_keys([0xA1, 0x01, 0, 0, 0, 0, 0, 0, 0, 0])
    
    def char_to_hid(self, char: str):
        """
        Converts a character to its corresponding HID key code.
        :param char: The character to convert
        :return: The HID key code
        """
        hid_map = {
            'a': 0x04, 'b': 0x05, 'c': 0x06, 'd': 0x07, 'e': 0x08,
            'f': 0x09, 'g': 0x0A, 'h': 0x0B, 'i': 0x0C, 'j': 0x0D,
            'k': 0x0E, 'l': 0x0F, 'm': 0x10, 'n': 0x11, 'o': 0x12,
            'p': 0x13, 'q': 0x14, 'r': 0x15, 's': 0x16, 't': 0x17,
            'u': 0x18, 'v': 0x19, 'w': 0x1A, 'x': 0x1B, 'y': 0x1C,
            'z': 0x1D, '1': 0x1E, '2': 0x1F, '3': 0x20, '4': 0x21,
            '5': 0x22, '6': 0x23, '7': 0x24, '8': 0x25, '9': 0x26,
            '0': 0x27, ' ': 0x2C, '.': 0x37, ',': 0x36, '-': 0x2D,
            '=': 0x2E, '[': 0x2F, ']': 0x30, ';': 0x33, '\\': 0x31,
            '/': 0x38, '`': 0x35
        }
        return hid_map.get(char.lower())

if __name__ == '__main__':
    sender = BluetoothSender()
    text = input("Enter the string to send over Bluetooth: ")
    sender.send_string(text)
  1. test the program with :
sudo python kb_send.py

@ccatkinso
Copy link

ccatkinso commented Jan 31, 2025

I am finding the device is being advertised as a speaker, headset etc. Is there a way to stop this?

I changed the sdp record to:

<?xml version="1.0" encoding="UTF-8" ?>
<record>
    <attribute id="0x0001">
        <sequence>
            <uuid value="00001124-0000-1000-8000-00805F9B34FB" />
        </sequence>
    </attribute>
    <attribute id="0x0004">
        <sequence>
            <sequence>
                <uuid value="00001000-0000-1000-8000-00805F9B34FB" />
            </sequence>
            <sequence>
                <uuid value="00001124-0000-1000-8000-00805F9B34FB" />
            </sequence>
        </sequence>
    </attribute>
    <attribute id="0x0005">
        <sequence>
            <uuid value="00001124-0000-1000-8000-00805F9B34FB" />
        </sequence>
    </attribute>
    <attribute id="0x0006">
        <uint16 value="0x0400" />
    </attribute>
    <attribute id="0x0009">
        <sequence>
            <sequence>
                <uuid value="00001124-0000-1000-8000-00805F9B34FB" />
                <uint16 value="0x0100"/>
            </sequence>
        </sequence>
    </attribute>
</record>

But it is still advertising:

root@raspberrypi:/# bluetoothctl show
Controller 2C:CF:67:B0:4B:F6 (public)
        Name: raspberrypi
        Alias: BT_HID_Keyboard
        Class: 0x006c0000
        Powered: yes
        Discoverable: yes
        DiscoverableTimeout: 0x00000000
        Pairable: yes
        UUID: A/V Remote Control        (0000110e-0000-1000-8000-00805f9b34fb)
        UUID: Handsfree Audio Gateway   (0000111f-0000-1000-8000-00805f9b34fb)
        UUID: PnP Information           (00001200-0000-1000-8000-00805f9b34fb)
        UUID: Audio Sink                (0000110b-0000-1000-8000-00805f9b34fb)
        UUID: Audio Source              (0000110a-0000-1000-8000-00805f9b34fb)
        UUID: A/V Remote Control Target (0000110c-0000-1000-8000-00805f9b34fb)
        UUID: Generic Access Profile    (00001800-0000-1000-8000-00805f9b34fb)
        UUID: Generic Attribute Profile (00001801-0000-1000-8000-00805f9b34fb)
        UUID: Device Information        (0000180a-0000-1000-8000-00805f9b34fb)
        UUID: Handsfree                 (0000111e-0000-1000-8000-00805f9b34fb)
        Modalias: usb:v1D6Bp0246d0542
        Discovering: no
        Roles: central
        Roles: peripheral
Advertising Features:
        ActiveInstances: 0x00 (0)
        SupportedInstances: 0x05 (5)
        SupportedIncludes: tx-power
        SupportedIncludes: appearance
        SupportedIncludes: local-name
        ```

@ukBaz
Copy link
Author

ukBaz commented Feb 1, 2025

To stop it advertising the audio profiles, then exclude them when you start bluetoothd e.g.

ExecStart=/usr/libexec/bluetooth/bluetoothd -P input,avrcp,a2dp

@ccatkinso
Copy link

To stop it advertising the audio profiles, then exclude them when you start bluetoothd e.g.

ExecStart=/usr/libexec/bluetooth/bluetoothd -P input,avrcp,a2dp

Thank you , will give that a shot

@sfc-gh-catkinson
Copy link

sfc-gh-catkinson commented Feb 9, 2025

Sadly that didn't seem to work, it just broke pairing

@amsound
Copy link

amsound commented Aug 8, 2025

@ukBaz This is such a find, thank you so much for taking the time to publish. I've had success sending standard keyboard HID codes, however not having much success sending consumer HID codes. Any idea how this could be accomplished?

@ukBaz
Copy link
Author

ukBaz commented Aug 8, 2025

Thanks for the suggestion—supporting media keys like Volume Up or Play/Pause would definitely be a useful enhancement, but it’s not straightforward given how the project is currently structured.

The setup acts as a Bluetooth HID proxy: it listens to a physical USB keyboard connected to the Raspberry Pi and forwards its key events over Bluetooth. While this works well for standard keyboard keys, media keys introduce a few challenges:

  • Physical Keyboard Limitations: Many USB keyboards don’t emit Consumer Control codes directly. Media keys are often handled separately and may not be exposed through the same input device as regular keys.

  • Linux Input Subsystem: Media key events might require listening to a different /dev/input/eventX device or using tools like evtest to detect them. This adds complexity to the input handling logic.

  • Bluetooth HID Descriptor: The current HID descriptor only supports standard keyboard reports. Supporting media keys would require extending the descriptor to include Consumer Control usage, defining a second report format, and handling multiple report IDs—all of which complicates the Bluetooth profile and report handling.

If anyone has insight into how to integrate media key support into this kind of setup—or examples of similar implementations—contributions are very welcome. The project has benefited from community fixes and improvements over time, and it’d be great to see it evolve further.

@amsound
Copy link

amsound commented Aug 8, 2025

I came to this gist from the angle of wanting to replicate/replace my Harmony hub. How it pairs to any devices and acts as a keyboard is so simple and brilliant, plus also sends media keys.

My input is a 'simple' Harmony Remote which I discovered can be paired with a Logitech Unifying USB Receiver (model U-0007 only) and am capturing these using evdev.

I did experiment with adjusting the SDP Record however it only kept breaking the keyboard integration! - Thank you again

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