Skip to content

Commit 319e73f

Browse files
committed
Initial support for Bluetooth HID devices
Tested on Playstation 3 and Apple TV 4
1 parent 0815992 commit 319e73f

File tree

9 files changed

+549
-1
lines changed

9 files changed

+549
-1
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
*.pyc
2+
__pycache__
3+
install

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2017 Todd McNeal
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,31 @@
1-
# bluefang
1+
# Bluefang
22
Bluetooth and HID utilities for Python 3
3+
4+
# Examples
5+
6+
## Discovering and pairing with device
7+
```python
8+
from bluefang import connection
9+
bluetooth = connection.Bluefang()
10+
bluetooth.registerProfile("/omnihub/profile")
11+
bluetooth.discoverable("on")
12+
bluetooth.pair()
13+
```
14+
You will be prompted to enter pin code after this. This is successful but since an L2CAP socket isn't active, an
15+
error will shown. If you run the snippet below and reconnect it will complete the pairing process.
16+
17+
```python
18+
from bluefang import connection
19+
bluetooth = connection.Bluefang()
20+
bluetooth.registerProfile("/omnihub/profile")
21+
bluetooth.discoverable("on")
22+
bluetooth.start_server()
23+
```
24+
25+
## Connecting to trusted device (WIP)
26+
```python
27+
from bluefang import connection
28+
bluetooth = connection.Bluefang()
29+
bluetooth.registerProfile("/omnihub/profile")
30+
bluetooth.connect("D0:03:4B:24:57:84")
31+
```

bluefang/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# -*- coding: utf-8 -*-

bluefang/agents.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# -*- coding: utf-8 -*-
2+
3+
import dbus
4+
import dbus.service
5+
import time
6+
7+
BLUEZ_SERVICE = "org.bluez"
8+
BLUEZ_ADAPTER = BLUEZ_SERVICE + ".Adapter1"
9+
BLUEZ_AGENT = BLUEZ_SERVICE + ".Agent1"
10+
BLUEZ_DEVICE = BLUEZ_SERVICE + ".Device1"
11+
AGENT_PATH = "/omnihub/agent"
12+
CAPABILITY = "NoInputNoOutput" #TODO Experiment with this
13+
14+
# For explanation of in and out signatures, see https://dbus.freedesktop.org/doc/dbus-python/doc/tutorial.html
15+
16+
def getManagedObjects():
17+
bus = dbus.SystemBus()
18+
manager = dbus.Interface(bus.get_object(BLUEZ_SERVICE, "/"), "org.freedesktop.DBus.ObjectManager")
19+
return manager.GetManagedObjects()
20+
21+
def findAdapter():
22+
objects = getManagedObjects();
23+
bus = dbus.SystemBus()
24+
for path, ifaces in iter(objects.items()):
25+
adapter = ifaces.get(BLUEZ_ADAPTER)
26+
if adapter is None:
27+
continue
28+
obj = bus.get_object(BLUEZ_SERVICE, path)
29+
return dbus.Interface(obj, BLUEZ_ADAPTER)
30+
raise Exception("Bluetooth adapter not found")
31+
32+
class BluefangAgent(dbus.service.Object):
33+
pin_code = None
34+
35+
def __init__(self):
36+
dbus.service.Object.__init__(self, dbus.SystemBus(), AGENT_PATH)
37+
self.pin_code = "0000"
38+
39+
print("Starting Bluefang agent")
40+
41+
@dbus.service.method(BLUEZ_AGENT, in_signature="os", out_signature="")
42+
def DisplayPinCode(self, device, pincode):
43+
print("DisplayPinCode invoked")
44+
45+
@dbus.service.method(BLUEZ_AGENT, in_signature="ou", out_signature="")
46+
def DisplayPasskey(self, device, passkey):
47+
print("PinCode ({}, {})".format(device, pincode))
48+
49+
@dbus.service.method(BLUEZ_AGENT, in_signature="o", out_signature="s")
50+
def RequestPinCode(self, device):
51+
print("Pairing with device [{}]".format(device))
52+
self.pin_code = input("Please enter the pin code: ")
53+
print("Trying with pin code: [{}]".format(self.pin_code))
54+
self.trustDevice(device)
55+
return self.pin_code
56+
57+
@dbus.service.method("org.bluez.Agent",
58+
in_signature="ou",
59+
out_signature="")
60+
def DisplayPasskey(self, device, passkey):
61+
print("Passkey ({}, {:06d})".format(device, passkey))
62+
63+
@dbus.service.method(BLUEZ_AGENT, in_signature="ou", out_signature="")
64+
def RequestConfirmation(self, device, passkey):
65+
"""Always confirm"""
66+
print("RequestConfirmation (%s, %06d)" % (device, passkey))
67+
time.sleep(2)
68+
print("Trusting device....")
69+
print(device)
70+
self.trustDevice(device)
71+
return
72+
73+
@dbus.service.method(BLUEZ_AGENT, in_signature="os", out_signature="")
74+
def AuthorizeService(self, device, uuid):
75+
"""Always authorize"""
76+
print("AuthorizeService method invoked")
77+
return
78+
79+
@dbus.service.method(BLUEZ_AGENT, in_signature="o", out_signature="u")
80+
def RequestPasskey(self, device):
81+
print("RequestPasskey")
82+
passkey = input("Please enter pass key: ")
83+
return dbus.UInt32(passkey)
84+
85+
@dbus.service.method(BLUEZ_AGENT, in_signature="o", out_signature="")
86+
def RequestAuthorization(self, device):
87+
"""Always authorize"""
88+
print("Authorizing device [{}]".format(self.device))
89+
return
90+
91+
@dbus.service.method(BLUEZ_AGENT, in_signature="", out_signature="")
92+
def Cancel(self):
93+
print("Pairing request canceled from device [{}]".format(self.device))
94+
95+
def trustDevice(self, path):
96+
print("Called trust device")
97+
bus = dbus.SystemBus()
98+
device_properties = dbus.Interface(bus.get_object(BLUEZ_SERVICE, path), "org.freedesktop.DBus.Properties")
99+
device_properties.Set(BLUEZ_DEVICE, "Trusted", True)
100+
101+
def registerAsDefault(self):
102+
print("Called register as default")
103+
bus = dbus.SystemBus()
104+
manager = dbus.Interface(bus.get_object(BLUEZ_SERVICE, "/org/bluez"), "org.bluez.AgentManager1")
105+
manager.RegisterAgent(AGENT_PATH, CAPABILITY)
106+
manager.RequestDefaultAgent(AGENT_PATH)
107+
108+
def unregister(self):
109+
print("Calling unregister")
110+
bus = dbus.SystemBus()
111+
manager = dbus.Interface(bus.get_object(BLUEZ_SERVICE, "/org/bluez"), "org.bluez.AgentManager1")
112+
manager.UnregisterAgent(AGENT_PATH)
113+
114+
def startPairing(self):
115+
print("Called start pairing")
116+
bus = dbus.SystemBus()
117+
adapter_path = findAdapter().object_path
118+
adapter = dbus.Interface(bus.get_object(BLUEZ_SERVICE, adapter_path), "org.freedesktop.DBus.Properties")
119+
adapter.Set(BLUEZ_ADAPTER, "Discoverable", True)
120+
121+
print("Waiting to pair with device")
122+

bluefang/connection.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
# -*- coding: utf-8 -*-
2+
3+
import bluetooth
4+
import dbus
5+
import dbus.mainloop.glib
6+
from gi.repository import GObject as gobject
7+
from queue import *
8+
import sys
9+
import time
10+
from threading import Thread
11+
12+
from bluefang import agents
13+
from bluefang import servicerecords
14+
15+
BLUEZ_SERVICE = "org.bluez"
16+
BLUEZ_ADAPTER = BLUEZ_SERVICE + ".Adapter1"
17+
BLUEZ_AGENT_MANAGER = BLUEZ_SERVICE + ".AgentManager1"
18+
BLUEZ_DEVICE = BLUEZ_SERVICE + ".Device1"
19+
BLUEZ_PROFILE_MANAGER = BLUEZ_SERVICE + ".ProfileManager1"
20+
21+
HID_UUID = "00001124-0000-1000-8000-00805f9b34fb"
22+
HID_CONTROL_PSM = 17
23+
HID_INTERRUPT_PSM = 19
24+
25+
q = Queue()
26+
27+
class Bluefang():
28+
def __init__(self):
29+
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
30+
self.bus = dbus.SystemBus()
31+
self.agent = agents.BluefangAgent()
32+
33+
def info(self):
34+
manager = dbus.Interface(self.bus.get_object(BLUEZ_SERVICE, "/"), "org.freedesktop.DBus.ObjectManager")
35+
device = manager.GetManagedObjects()['/org/bluez/hci0'] # Requires correct permissions in /etc/dbus-1/system-local.conf
36+
#TODO Pull device name dynamically
37+
adapter = device[BLUEZ_ADAPTER]
38+
39+
print(adapter)
40+
return adapter
41+
42+
def connect(self, deviceAddress):
43+
"""
44+
Attempt to connect to the given Device.
45+
"""
46+
47+
socket = bluetooth.BluetoothSocket(bluetooth.L2CAP)
48+
print("Attempting to connect to device %s" % deviceAddress)
49+
socket.connect((deviceAddress, 0x1001))
50+
print("Connected! Spawning thread")
51+
connection = L2CAPWorker(socket, deviceAddress)
52+
connection.daemon = True
53+
connection.start()
54+
# Device Connect
55+
#devicePath = '/org/bluez/hci0/dev_' + deviceAddress.replace(':', '_')
56+
#device = dbus.Interface(self.bus.get_object(BLUEZ_SERVICE, devicePath), BLUEZ_DEVICE)
57+
#device.Connect()
58+
59+
def start_server(self):
60+
control_server_socket = bluetooth.BluetoothSocket(bluetooth.L2CAP)
61+
control_server_socket.bind(("", HID_CONTROL_PSM))
62+
bluetooth.set_l2cap_mtu(control_server_socket, 64)
63+
control_server_socket.listen(1)
64+
65+
interrupt_server_socket = bluetooth.BluetoothSocket(bluetooth.L2CAP)
66+
interrupt_server_socket.bind(("", HID_INTERRUPT_PSM))
67+
bluetooth.set_l2cap_mtu(interrupt_server_socket, 64)
68+
interrupt_server_socket.listen(1)
69+
70+
print("Listening on both PSMs")
71+
72+
control_socket, control_address = control_server_socket.accept()
73+
interrupt_socket, interrupt_address = interrupt_server_socket.accept()
74+
75+
print("Spawned control and interrupt connection threads")
76+
77+
control = L2CAPWorker(control_socket, control_address, 'receive')
78+
control.daemon = True
79+
control.start()
80+
81+
interrupt = L2CAPWorker(interrupt_socket, interrupt_address, 'send')
82+
interrupt.daemon = True
83+
interrupt.start()
84+
85+
while 1:
86+
command = input("Enter a command: ")
87+
q.put(command)
88+
89+
90+
def disconnect(self, blah):
91+
print("DISCONNECT %s" % blah) #TODO implement this
92+
93+
def discoverable(self, state):
94+
deviceManager = dbus.Interface(self.bus.get_object(BLUEZ_SERVICE, "/org/bluez/hci0"), 'org.freedesktop.DBus.Properties')
95+
if state == "on":
96+
deviceManager.Set(BLUEZ_ADAPTER, 'Discoverable', True)
97+
elif state == "off":
98+
deviceManager.Set(BLUEZ_ADAPTER, 'Discoverable', True)
99+
else:
100+
raise Error("Unsupported state %s. Supported states: on, off" % state)
101+
102+
def pair(self):
103+
self.agent.registerAsDefault()
104+
self.agent.startPairing()
105+
mainloop = gobject.MainLoop()
106+
mainloop.run()
107+
108+
def registerProfile(self, profilePath):
109+
print("REGISTER %s" % profilePath)
110+
profileManager = dbus.Interface(self.bus.get_object(BLUEZ_SERVICE, "/org/bluez"), BLUEZ_PROFILE_MANAGER)
111+
profileManager.RegisterProfile(
112+
profilePath,
113+
HID_UUID,
114+
{
115+
"Name": "Omnihub HID",
116+
"AutoConnect": False,
117+
"ServiceRecord": servicerecords.HID_PROFILE
118+
}
119+
)
120+
#TODO this doesn't register from CLI because the profile is unregistered when program exits
121+
122+
def unregisterProfile(self, profilePath):
123+
print("UNREGISTER %s" % profilePath)
124+
profileManager = dbus.Interface(self.bus.get_object(BLUEZ_SERVICE, "/org/bluez"), BLUEZ_PROFILE_MANAGER)
125+
profileManager.UnregisterProfile(profilePath)
126+
#TODO handle the 'does not exist' error more gracefully?
127+
128+
def sendHIDMessage(self, msg):
129+
print("SEND MESSAGE %s" % msg)
130+
131+
def isConnectionEstablished(self):
132+
print("Connection established?")
133+
return True
134+
135+
class L2CAPWorker(Thread):
136+
def __init__(self, socket, address, sendOrReceive):
137+
Thread.__init__(self)
138+
self.socket = socket
139+
self.address = address
140+
self.sendOrReceive = sendOrReceive
141+
142+
def run(self):
143+
size = 500
144+
try:
145+
if self.sendOrReceive == 'send':
146+
print("Sending on address: ${0}".format(self.address))
147+
while True:
148+
command = q.get()
149+
print("Received command: " + command)
150+
if command == "left":
151+
self.socket.send(bytes(bytearray((0xA1, 0x01, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00))))
152+
self.socket.send(bytes(bytearray((0xA1, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))))
153+
elif command == "right":
154+
self.socket.send(bytes(bytearray((0xA1, 0x01, 0x00, 0x00, 0x4F, 0x00, 0x00, 0x00, 0x00, 0x00))))
155+
self.socket.send(bytes(bytearray((0xA1, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))))
156+
elif command == "up":
157+
self.socket.send(bytes(bytearray((0xA1, 0x01, 0x00, 0x00, 0x52, 0x00, 0x00, 0x00, 0x00, 0x00))))
158+
self.socket.send(bytes(bytearray((0xA1, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))))
159+
elif command == "down":
160+
self.socket.send(bytes(bytearray((0xA1, 0x01, 0x00, 0x00, 0x51, 0x00, 0x00, 0x00, 0x00, 0x00))))
161+
self.socket.send(bytes(bytearray((0xA1, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))))
162+
elif command == "enter":
163+
self.socket.send(bytes(bytearray((0xA1, 0x01, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x00, 0x00))))
164+
self.socket.send(bytes(bytearray((0xA1, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))))
165+
else:
166+
print("Unknown command: " + command)
167+
q.task_done()
168+
169+
#self.socket.send(chr(0xA1) + chr(0x00) + chr(0x00) + chr(0x00) + chr(0x00))
170+
#self.socket.send(chr(0xA1) + chr(0x54)) #up
171+
#self.socket.send(chr(0xA1) + chr(0x55)) #right
172+
#self.socket.send(chr(0xA1) + chr(0x56)) #down
173+
#self.socket.send(chr(0xA1) + chr(0x57)) #left
174+
else:
175+
print("Receiving on address: ${0}".format(self.address))
176+
while 1:
177+
data = self.socket.recv(size)
178+
if data:
179+
print("Received data:")
180+
print(':'.join(hex(x) for x in data))
181+
if data[0] == 0x71:
182+
print("Server wants to use Report Protocol Mode. Acknowledging.")
183+
self.socket.send(chr(0x00)) # Acknowledge that we will use this protocol mode
184+
self.socket.send(chr(0xA1) + chr(0x04))
185+
finally:
186+
print("Closing socket for address: ${0}".format(self.address))
187+
#client.close()
188+
self.socket.close()
189+
190+
"""
191+
Every message should have the following:
192+
193+
L2CAP Length (2 bytes)
194+
L2CAP CID (2 bytes)
195+
HIDP Header (1 byte)
196+
HID Payload (variable)
197+
"""

0 commit comments

Comments
 (0)