Skip to content

Instantly share code, notes, and snippets.

@conath
Last active September 19, 2024 12:17
Show Gist options
  • Save conath/c606d95d58bbcb50e9715864eeeecf07 to your computer and use it in GitHub Desktop.
Save conath/c606d95d58bbcb50e9715864eeeecf07 to your computer and use it in GitHub Desktop.
A close to complete CoreData Bluetooth peripheral implementation of the Bluetooth HID Keyboard standard. As of iOS 14, the services are blocked by the system so it's impossible to make an iOS device act as a bluetooth keyboard, for example.
import Foundation
extension Data {
init?(hexString: String) {
let len = hexString.count / 2
var data = Data(capacity: len)
for i in 0..<len {
let j = hexString.index(hexString.startIndex, offsetBy: i*2)
let k = hexString.index(j, offsetBy: 2)
let bytes = hexString[j..<k]
if var num = UInt8(bytes, radix: 16) {
data.append(&num, count: 1)
} else {
return nil
}
}
self = data
}
}
import CoreBluetooth
import os
class MPBluetoothController: NSObject {
var shouldReconnect = true
private(set) var canSendData = false
private var peripheralManager: CBPeripheralManager!
private var characteristic: CBMutableCharacteristic?
private var connectedCentral: CBCentral?
override init() {
super.init()
peripheralManager = CBPeripheralManager(delegate: self, queue: nil, options: [CBPeripheralManagerOptionShowPowerAlertKey: true])
}
// MARK: - Helper Methods
private func setupPeripheral() {
// Build our service.
// MARK: Generic Access Service
let genericAccessService = CBMutableService(type: CBUUID(string: "1800"), primary: false)
let appearenceUUID = CBUUID(string: "2A01")
let appearanceValue = Data(NSData(bytes: [0xC103] as [UInt16], length: 2))
let appearenceCharacteristic = CBMutableCharacteristic(type: appearenceUUID, properties: [.read], value: appearanceValue, permissions: [.readable])
genericAccessService.characteristics = [appearenceCharacteristic]
peripheralManager.add(genericAccessService)
// MARK: Device Information Service
let deviceInformationService = CBMutableService(type: CBUUID(string: "180A"), primary: false)
let pnpUUID = CBUUID(string: "2A50")
let pnpValue = Data(NSData(bytes: [0x00, 0x4700, 0xFFFF, 0xFFFF] as [UInt16], length: 8))
let pnpCharacteristic = CBMutableCharacteristic(type: pnpUUID, properties: [.read], value: pnpValue, permissions: [.readable])
deviceInformationService.characteristics = [pnpCharacteristic]
peripheralManager.add(deviceInformationService)
// MARK: Human Interface Device Service
// See https://www.bluetooth.com/xml-viewer/?src=https://www.bluetooth.com/wp-content/uploads/Sitecore-Media-Library/Gatt/Xml/Services/org.bluetooth.service.human_interface_device.xml
// Reference https://docs.silabs.com/bluetooth/latest/code-examples/applications/ble-hid-keyboard#gatt-database-for-keyboard-example
let hidService = CBMutableService(type: MPHIDService.serviceUUID, primary: true)
let hidInfoUUID = CBUUID(string: "2A4A")
let hidInfoValue = Data(NSData(bytes: [0x0111, 0x0002] as [UInt16], length: 4))
let hidInfoCharacteristic = CBMutableCharacteristic(type: hidInfoUUID, properties: [.read], value: hidInfoValue, permissions: [.readable])
let protocolModeUUID = CBUUID(string: "2A4E")
let protocolModeValue = Data(NSData(bytes: [0x00] as [UInt8], length: 1)) // report mode 00 is boot, 01 is report
let protocolModeCharacteristic = CBMutableCharacteristic(type: protocolModeUUID, properties: [.read], value: protocolModeValue, permissions: [.readable])
let reportMapUUID = CBUUID(string: "2A4B")
let reportMapData = Data(MPHIDService.hidReportDescriptor)
let reportMapCharacteristic = CBMutableCharacteristic(type: reportMapUUID, properties: [.read], value: reportMapData, permissions: [.readable, .readEncryptionRequired])
// let hidControlPointUUID = CBUUID(string: "2A4C")
// let hidControlPointCharacteristic = CBMutableCharacteristic(type: hidControlPointUUID, properties: [.writeWithoutResponse], value: nil, permissions: [.writeable])
let reportUUID = CBUUID(string: "2A22")//"2A4D") would be report mode
let reportCharacteristic = CBMutableCharacteristic(type: reportUUID, properties: [.read, .notify], value: nil, permissions: [.readable])
let reportDescriptorUUID = CBUUID(string: "2908")
let reportDescriptorValue = Data(NSData(bytes: [0x0001] as [UInt16], length: 2))
let reportDescriptorCharacteristic = CBMutableCharacteristic(type: reportDescriptorUUID, properties: [.read], value: reportDescriptorValue, permissions: [.readable, .readEncryptionRequired])
hidService.characteristics = [hidInfoCharacteristic, protocolModeCharacteristic, reportMapCharacteristic, reportCharacteristic, reportDescriptorCharacteristic]
// And add it to the peripheral manager.
peripheralManager.add(hidService)
// Save the characteristic for later.
self.characteristic = reportCharacteristic
let serviceUUIDs = [
CBUUID(string: "1800"),
CBUUID(string: "180A"),
MPHIDService.serviceUUID
]
peripheralManager.startAdvertising([CBAdvertisementDataServiceUUIDsKey: serviceUUIDs])
print(serviceUUIDs)
// MARK: WORKING CODE but with limited functionality
/*
let characteristic = CBMutableCharacteristic(type: MPHIDService.characteristicUUID,
properties: [.notify, .writeWithoutResponse],
value: nil,
permissions: [.readable, .writeable])
// Create a service from the characteristic.
let service = CBMutableService(type: MPHIDService.serviceUUID, primary: true)
// Add the characteristic to the service.
service.characteristics = [characteristic]
// And add it to the peripheral manager.
peripheralManager.add(service)
// Save the characteristic for later.
self.characteristic = characteristic
peripheralManager.startAdvertising([CBAdvertisementDataServiceUUIDsKey: [MPHIDService.serviceUUID]])
*/
}
func sendKeystroke(_ keyStroke: MPHIDService.KeyStroke, _ keyState: MPHIDService.KeyState) {
guard let characteristic = characteristic else {
return
}
var didSend = true
while didSend {
// Get the data we need to send
let data = MPHIDService.getReportDataFor(keyStroke: keyStroke, keyState: keyState)
// Work out how big it can be
let amountToSend = data.count
if let mtu = connectedCentral?.maximumUpdateValueLength {
guard mtu > amountToSend else {
os_log("Central can't receive \(amountToSend) bytes at a time - this is not handled")
// todo disconnect?
fatalError("Central can't receive \(amountToSend) bytes at a time - this is not handled")
}
}
// TODO key up/down - rest of this function is not properly implemented
//let chunk = MPHIDService.Data(keyStroke: keyStroke, state: keyState).stringRepresentation.data(using: .utf8)!
// Send it
didSend = peripheralManager.updateValue(data, for: characteristic, onSubscribedCentrals: nil)
// If it didn't work, drop out and wait for the callback
if !didSend {
print("Didn't send")
return
}
// let stringFromData = String(data: data, encoding: .ascii)
// os_log("Sent %d bytes: %s", data.count, String(describing: stringFromData))
}
}
}
// MARK: Implementation of CBPeripheralManagerDelegate
extension MPBluetoothController: CBPeripheralManagerDelegate {
/*
* Required protocol method. A full app should take care of all the possible states,
* but we're just waiting for to know when the CBPeripheralManager is ready
*
* Starting from iOS 13.0, if the state is CBManagerStateUnauthorized, you
* are also required to check for the authorization state of the peripheral to ensure that
* your app is allowed to use bluetooth
*/
internal func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
// TODO tell UI if bluetooth is off
// advertisingSwitch.isEnabled = peripheral.state == .poweredOn
switch peripheral.state {
case .poweredOn:
// ... so start working with the peripheral
os_log("CBManager is powered on")
setupPeripheral()
case .poweredOff:
os_log("CBManager is not powered on")
// TODO deal with all the states accordingly
return
case .resetting:
os_log("CBManager is resetting")
// TODO deal with all the states accordingly
return
case .unauthorized:
// TODO deal with all the states accordingly
switch CBPeripheralManager.authorization {
case .denied:
os_log("You are not authorized to use Bluetooth")
case .restricted:
os_log("Bluetooth is restricted")
default:
os_log("Unexpected authorization")
}
return
case .unknown:
os_log("CBManager state is unknown")
// TODO deal with all the states accordingly
return
case .unsupported:
os_log("Bluetooth is not supported on this device")
// TODO deal with all the states accordingly
return
@unknown default:
os_log("A previously unknown peripheral manager state occurred")
// TODO deal with yet unknown cases that might occur in the future
return
}
}
/*
* Catch when someone subscribes to our characteristic, then start sending them data
*/
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
os_log("Central subscribed to characteristic")
// save central
connectedCentral = central
// Start sending
canSendData = true
}
/*
* Recognize when the central unsubscribes
*/
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
os_log("Central unsubscribed from characteristic")
connectedCentral = nil
}
func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) {
if let error = error {
print(String(describing: error))
} else {
print("Did start advertising.")
}
}
/*
* This callback comes in when the PeripheralManager is ready to send the next chunk of data.
* This is to ensure that packets will arrive in the order they are sent
*/
// func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
// TODO do I need this?
// }
/*
* This callback comes in when the PeripheralManager received write to characteristics
*/
// func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
// for aRequest in requests {
// guard let requestValue = aRequest.value,
// let stringFromData = String(data: requestValue, encoding: .utf8) else {
// continue
// }
//
// os_log("Received write request of %d bytes: %s", requestValue.count, stringFromData)
// self.textView.text = stringFromData
// }
// }
}
import Foundation
import CoreBluetooth
struct MPHIDService {
static let minRSSI = -70
static let serviceUUID = CBUUID(string: "1812")
static let hidReportDescriptor = NSData(bytes:
[
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x06, // Usage (Keyboard)
0xA1, 0x01, // Collection (Application)
0x05, 0x07, // Usage Page (Keyboard)
0x19, 0xE0, // Usage Minimum (Keyboard LeftControl)
0x29, 0xE7, // Usage Maximum (Keyboard Right GUI)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x08, // Report Count (9)
0x81, 0x02, // Input (Data, Variable, Absolute) Modifier byte
0x95, 0x01, // Report Size (1)
0x75, 0x08, // Report Count (8)
0x81, 0x03, // Input (Constant) Reserved byte
0x95, 0x06, // Report Count (6)
0x75, 0x08, // Report Size (8)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x65, // Logical Maximum (101)
0x05, 0x07, // Usage Page (Key Codes)
0x05, 0x01, // Usage Minimum (Reserved (no event indicated))
0x05, 0x01, // Usage Maximum (Keyboard Application)
0x05, 0x01, // Input (Data,Array) Key arrays (6 bytes)
0xC0 // End Collection
] as [UInt8], length: 45)
static func getReportDataFor(keyStroke: KeyStroke, keyState: KeyState) -> Data {
var chunk: UInt8!
switch keyState {
case .Down:
switch keyStroke {
case .Left:
chunk = 0x7F
case .Right:
chunk = 0x59
}
default:
chunk = 0x00
}
let values = [0x00, 0x00, chunk, 0x00, 0x00, 0x00, 0x00, 0x00] as [UInt8]
let data = Data(NSData(bytes: values, length: 8))
return data
}
enum KeyStroke: UInt16 {
case Left = 79
case Right = 89
static let lengthBytes = 2
}
enum KeyState: String {
case Up = "up"
case Down = "do"
case None = " "
}
}
@Taati89
Copy link

Taati89 commented May 13, 2024

I found the Bluetouch app on iOS. It can function as a mouse and keyboard. I'm not sure how they use Bluetooth. Do you happen to know?

@conath
Copy link
Author

conath commented May 13, 2024

Thank you for the pointer @Taati89 !

I downloaded that app and attempted to connect to a Window PC, but was unsuccessful. In the logs of the app it seems like they do try to advertise a HID service but does not log what they are using exactly.

From the look of it it seems like a cross platform app so maybe the answer can be found in some flutter or other language bluetooth plugin.

You could also try running their app on iOS while connected to a Mac and capturing a system log. It could reveal additional information.

@Taati89
Copy link

Taati89 commented May 16, 2024

I tried your code and encountered an issue when calling peripheralManager.add(genericAccessService). The function public func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) gets called and receives the error "The specified UUID is not allowed for this operation."

@conath
Copy link
Author

conath commented May 16, 2024

@Taati89 exactly! Like I stated in the Gist description, the HID service UUID is blocked by iOS.

@Taati89
Copy link

Taati89 commented May 17, 2024

@conath
I tried changing the UUID from 1812 to 00001812-0000-1000-8000-00805F9B34FB and it successfully added the service.

Then, when I called peripheralManager.startAdvertising with UUID 00001812-0000-1000-8000-00805F9B34FB, the computer or mobile device would trigger the function public func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) upon connection.

The requests would have request.characteristic.uuid == Boot Keyboard Input Report (0x2A22) and request.characteristic.uuid == Report (0x2A4D) respectively.

After I responded with peripheralManager?.respond(to: request, withResult: .success), it seemed that my app was recognized as an input device.

@Taati89
Copy link

Taati89 commented May 17, 2024

Additionally, I tried connecting to an Android device. When connected, this function is called:

public func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {

When I called peripheralManager.updateValue, it could type as expected.

I wonder why on Android it triggers didSubscribeTo but on a Windows computer it calls didReceiveRead.

@LOHITH1988
Copy link

@Taati89, I tried as you mentioned and changed the UUID from 1812 to 00001812-0000-1000-8000-00805F9B34FB on Mac. It got paired, but didReceiveRead or didSubscribeTo didn't get called. The last delegate method that got called is peripheralManagerDidStartAdvertising. Did you make any progress on this? There is an app called Blue Touch in the App Store that is working properly with this. I would highly appreciate any updates.

@Taati89
Copy link

Taati89 commented Jul 17, 2024

@Taati89, I tried as you mentioned and changed the UUID from 1812 to 00001812-0000-1000-8000-00805F9B34FB on Mac. It got paired, but didReceiveRead or didSubscribeTo didn't get called. The last delegate method that got called is peripheralManagerDidStartAdvertising. Did you make any progress on this? There is an app called Blue Touch in the App Store that is working properly with this. I would highly appreciate any updates.

I tested it with Windows, and I can now send typing data, but I still have an issue with reconnecting after killing the app.

One difference from the code is this:

        let reportDescriptorUUID = CBUUID(string: "2908")
        let reportDescriptorValue = Data(NSData(bytes: [0x0001] as [UInt16], length: 2))
        let reportDescriptorCharacteristic = CBMutableCharacteristic(type: reportDescriptorUUID, properties: [.read], value: reportDescriptorValue, permissions: [.readable, .readEncryptionRequired])
        
        hidService.characteristics = [hidInfoCharacteristic, protocolModeCharacteristic, reportMapCharacteristic, reportCharacteristic, reportDescriptorCharacteristic]

2908 is the descriptor, and it needs to be added to the reportCharacteristic like this:

        let cccd = CBMutableDescriptor(type: CBUUID(string: "2908"),  value: Data(NSData(bytes: [0x00, 0x01] as [UInt8], length: 2)))
        reportCharacteristic.descriptors = [cccd]

And I use the hidReportDescriptor as follows, https://www.usb.org/sites/default/files/documents/hid1_11.pdf Page 79: E.6 Report Descriptor (Keyboard)

        NSData(bytes:
            [
                0x05, 0x01,  // USAGE_PAGE
                0x09, 0x06,  // USAGE
                0xA1, 0x01,  // COLLECTION
                0x05, 0x07,  // USAGE_PAGE
                0x19, 0xE0,  // USAGE_MINIMUM
                0x29, 0xE7,  // USAGE_MAXIMUM
                0x15, 0x00,  // LOGICAL_MINIMUM
                0x25, 0x01,  // LOGICAL_MAXIMUM
                0x75, 0x01,  // REPORT_SIZE 1 byte (Modifier)
                0x95, 0x08,  // REPORT_COUNT
                0x81, 0x02,  // INPUT   Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position
                0x95, 0x01,  // REPORT_COUNT  1 byte (Reserved)
                0x75, 0x08,  // REPORT_SIZE
                0x81, 0x01,  // INPUT  Const,Array,Abs,No Wrap,Linear,Preferred State,No Null Position
                0x95, 0x05,  // REPORT_COUNT  5 bits (Num lock, Caps lock, Scroll lock, Compose, Kana)
                0x75, 0x01,  // REPORT_SIZE
                0x05, 0x08,  // USAGE_PAGE LEDs
                0x19, 0x01,  // USAGE_MINIMUM  Num Lock
                0x29, 0x05,  // USAGE_MAXIMUM  Kana
                0x91, 0x02,  // OUTPUT  Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile
                0x95, 0x01,  // REPORT_COUNT  3 bits (Padding)
                0x75, 0x03,  // REPORT_SIZE
                0x91, 0x01,  // OUTPUT Const,Array,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile
                0x95, 0x06,  // REPORT_COUNT  6 bytes (Keys)
                0x75, 0x08,  // REPORT_SIZE
                0x15, 0x00,  // LOGICAL_MINIMUM
                0x25, 0x65,  // LOGICAL_MAXIMUM  101 keys
                0x05, 0x07,  // USAGE_PAGE  Kbrd/Keypad
                0x19, 0x00,  // USAGE_MINIMUM
                0x29, 0x65,  // USAGE_MAXIMUM
                0x81, 0x00,  // INPUT  Data,Array,Abs,No Wrap,Linear,Preferred State,No Null Position
                0xC0,           // END_COLLECTION
            ] as [UInt8], length: 63)

In my current tests, it works when I press 'Forget Device' and disconnect from both the computer and the phone first, as if they had never connected before, then start connecting from the computer. (I still have an issue with reconnecting after killing the app.)

@LOHITH1988
Copy link

@Taati89 , I highly appreciate your support. It's working for me on Mac.

@shaotianchi
Copy link

@Taati89, I tried as you mentioned and changed the UUID from 1812 to 00001812-0000-1000-8000-00805F9B34FB on Mac. It got paired, but didReceiveRead or didSubscribeTo didn't get called. The last delegate method that got called is peripheralManagerDidStartAdvertising. Did you make any progress on this? There is an app called Blue Touch in the App Store that is working properly with this. I would highly appreciate any updates.

I tested it with Windows, and I can now send typing data, but I still have an issue with reconnecting after killing the app.

One difference from the code is this:

        let reportDescriptorUUID = CBUUID(string: "2908")
        let reportDescriptorValue = Data(NSData(bytes: [0x0001] as [UInt16], length: 2))
        let reportDescriptorCharacteristic = CBMutableCharacteristic(type: reportDescriptorUUID, properties: [.read], value: reportDescriptorValue, permissions: [.readable, .readEncryptionRequired])
        
        hidService.characteristics = [hidInfoCharacteristic, protocolModeCharacteristic, reportMapCharacteristic, reportCharacteristic, reportDescriptorCharacteristic]

2908 is the descriptor, and it needs to be added to the reportCharacteristic like this:

        let cccd = CBMutableDescriptor(type: CBUUID(string: "2908"),  value: Data(NSData(bytes: [0x00, 0x01] as [UInt8], length: 2)))
        reportCharacteristic.descriptors = [cccd]

And I use the hidReportDescriptor as follows, https://www.usb.org/sites/default/files/documents/hid1_11.pdf Page 79: E.6 Report Descriptor (Keyboard)

        NSData(bytes:
            [
                0x05, 0x01,  // USAGE_PAGE
                0x09, 0x06,  // USAGE
                0xA1, 0x01,  // COLLECTION
                0x05, 0x07,  // USAGE_PAGE
                0x19, 0xE0,  // USAGE_MINIMUM
                0x29, 0xE7,  // USAGE_MAXIMUM
                0x15, 0x00,  // LOGICAL_MINIMUM
                0x25, 0x01,  // LOGICAL_MAXIMUM
                0x75, 0x01,  // REPORT_SIZE 1 byte (Modifier)
                0x95, 0x08,  // REPORT_COUNT
                0x81, 0x02,  // INPUT   Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position
                0x95, 0x01,  // REPORT_COUNT  1 byte (Reserved)
                0x75, 0x08,  // REPORT_SIZE
                0x81, 0x01,  // INPUT  Const,Array,Abs,No Wrap,Linear,Preferred State,No Null Position
                0x95, 0x05,  // REPORT_COUNT  5 bits (Num lock, Caps lock, Scroll lock, Compose, Kana)
                0x75, 0x01,  // REPORT_SIZE
                0x05, 0x08,  // USAGE_PAGE LEDs
                0x19, 0x01,  // USAGE_MINIMUM  Num Lock
                0x29, 0x05,  // USAGE_MAXIMUM  Kana
                0x91, 0x02,  // OUTPUT  Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile
                0x95, 0x01,  // REPORT_COUNT  3 bits (Padding)
                0x75, 0x03,  // REPORT_SIZE
                0x91, 0x01,  // OUTPUT Const,Array,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile
                0x95, 0x06,  // REPORT_COUNT  6 bytes (Keys)
                0x75, 0x08,  // REPORT_SIZE
                0x15, 0x00,  // LOGICAL_MINIMUM
                0x25, 0x65,  // LOGICAL_MAXIMUM  101 keys
                0x05, 0x07,  // USAGE_PAGE  Kbrd/Keypad
                0x19, 0x00,  // USAGE_MINIMUM
                0x29, 0x65,  // USAGE_MAXIMUM
                0x81, 0x00,  // INPUT  Data,Array,Abs,No Wrap,Linear,Preferred State,No Null Position
                0xC0,           // END_COLLECTION
            ] as [UInt8], length: 63)

In my current tests, it works when I press 'Forget Device' and disconnect from both the computer and the phone first, as if they had never connected before, then start connecting from the computer. (I still have an issue with reconnecting after killing the app.)

My mac cannot find my iPhone after 「Did start advertising」I dont know where am i wrong , here's my code :
https://github.com/shaotianchi/TakaKeyboard

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