From e27348dcd7fe0ecf98b23c40c3096b8788f6b9c3 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 02:15:32 -0400 Subject: [PATCH 01/10] Add HIDP C interop structures --- .../BluetoothLinux/Internal/CInterop.swift | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/Sources/BluetoothLinux/Internal/CInterop.swift b/Sources/BluetoothLinux/Internal/CInterop.swift index 6e65542..94d2854 100644 --- a/Sources/BluetoothLinux/Internal/CInterop.swift +++ b/Sources/BluetoothLinux/Internal/CInterop.swift @@ -417,6 +417,173 @@ public extension CInterop { } } +public extension CInterop { + + /// `char name[128]` fixed-size device name buffer (as 32-bit words to preserve C layout). + typealias HIDPDeviceName = (UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32, UInt32) + + /// `hidp_connadd_req` + struct HIDPConnectionAddRequest { + + /// int ctrl_sock; + public var controlSocket: CInt + + /// int intr_sock; + public var interruptSocket: CInt + + /// uint16_t parser; + public var parser: UInt16 + + /// uint16_t rd_size; + public var reportDescriptorSize: UInt16 + + /// uint8_t *rd_data; + public var reportDescriptor: UnsafeMutablePointer? + + /// uint8_t country; + public var country: UInt8 + + /// uint8_t subclass; + public var subclass: UInt8 + + /// uint16_t vendor; + public var vendor: UInt16 + + /// uint16_t product; + public var product: UInt16 + + /// uint16_t version; + public var version: UInt16 + + /// uint32_t flags; + public var flags: UInt32 + + /// uint32_t idle_to; + public var idleTimeout: UInt32 + + /// char name[128]; + public var name: HIDPDeviceName + + public init( + controlSocket: CInt, + interruptSocket: CInt, + parser: UInt16, + reportDescriptorSize: UInt16 = 0, + reportDescriptor: UnsafeMutablePointer? = nil, + country: UInt8 = 0, + subclass: UInt8 = 0, + vendor: UInt16 = 0, + product: UInt16 = 0, + version: UInt16 = 0, + flags: UInt32 = 0, + idleTimeout: UInt32 = 0, + name: HIDPDeviceName = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + ) { + self.controlSocket = controlSocket + self.interruptSocket = interruptSocket + self.parser = parser + self.reportDescriptorSize = reportDescriptorSize + self.reportDescriptor = reportDescriptor + self.country = country + self.subclass = subclass + self.vendor = vendor + self.product = product + self.version = version + self.flags = flags + self.idleTimeout = idleTimeout + self.name = name + } + } +} + +public extension CInterop { + + /// `hidp_conndel_req` + struct HIDPConnectionDeleteRequest: Equatable, Hashable { + + /// `bdaddr_t` (little endian) + public var address: BluetoothAddress + + /// uint32_t flags; + public var flags: UInt32 + + public init( + address: BluetoothAddress, + flags: UInt32 + ) { + self.address = address + self.flags = flags + } + } +} + +public extension CInterop { + + /// `hidp_conninfo` + struct HIDPConnectionInformation { + + /// `bdaddr_t` (little endian) + public var address: BluetoothAddress + + /// uint32_t flags; + public var flags: UInt32 + + /// uint16_t state; + public var state: UInt16 + + /// uint16_t vendor; + public var vendor: UInt16 + + /// uint16_t product; + public var product: UInt16 + + /// uint16_t version; + public var version: UInt16 + + /// char name[128]; + public var name: HIDPDeviceName + + public init( + address: BluetoothAddress = .zero, + flags: UInt32 = 0, + state: UInt16 = 0, + vendor: UInt16 = 0, + product: UInt16 = 0, + version: UInt16 = 0, + name: HIDPDeviceName = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + ) { + self.address = address + self.flags = flags + self.state = state + self.vendor = vendor + self.product = product + self.version = version + self.name = name + } + } +} + +public extension CInterop { + + /// `hidp_connlist_req` + struct HIDPConnectionListRequest { + + /// uint32_t cnum; + public var count: UInt32 + + /// struct hidp_conninfo *ci; + public var connections: UnsafeMutablePointer? + + public init( + count: UInt32, + connections: UnsafeMutablePointer? = nil + ) { + self.count = count + self.connections = connections + } + } +} + public extension CInterop { /// `sco_conninfo` SCO Connection Information From a4ed7a1eb4a3473196100391d62173f2685b094b Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 02:15:33 -0400 Subject: [PATCH 02/10] Add HIDP connection flags --- .../HIDP/HIDPConnectionFlag.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 Sources/BluetoothLinux/HIDP/HIDPConnectionFlag.swift diff --git a/Sources/BluetoothLinux/HIDP/HIDPConnectionFlag.swift b/Sources/BluetoothLinux/HIDP/HIDPConnectionFlag.swift new file mode 100644 index 0000000..bc07a68 --- /dev/null +++ b/Sources/BluetoothLinux/HIDP/HIDPConnectionFlag.swift @@ -0,0 +1,24 @@ +// +// HIDPConnectionFlag.swift +// BluetoothLinux +// + +/// HIDP connection flags. +@frozen +public struct HIDPConnectionFlag: OptionSet, Equatable, Hashable, Sendable { + + public let rawValue: UInt32 + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } +} + +public extension HIDPConnectionFlag { + + /// Delete the session when the device sends a virtual cable unplug. + static var virtualCableUnplug: HIDPConnectionFlag { HIDPConnectionFlag(rawValue: 1 << 0) } + + /// Use the boot protocol instead of the report protocol. + static var bootProtocolMode: HIDPConnectionFlag { HIDPConnectionFlag(rawValue: 1 << 1) } +} From 40bbca61393af617c993c41c88539113b260c7a0 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 02:15:33 -0400 Subject: [PATCH 03/10] Add HIDP connection state --- .../HIDP/HIDPConnectionState.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 Sources/BluetoothLinux/HIDP/HIDPConnectionState.swift diff --git a/Sources/BluetoothLinux/HIDP/HIDPConnectionState.swift b/Sources/BluetoothLinux/HIDP/HIDPConnectionState.swift new file mode 100644 index 0000000..b23d3ff --- /dev/null +++ b/Sources/BluetoothLinux/HIDP/HIDPConnectionState.swift @@ -0,0 +1,19 @@ +// +// HIDPConnectionState.swift +// BluetoothLinux +// + +/// HIDP connection state. +public enum HIDPConnectionState: UInt16, CaseIterable, Sendable { + + case unknown = 0x00 + case connected = 0x01 + case open = 0x02 + case bound = 0x03 + case listening = 0x04 + case connecting = 0x05 + case connecting2 = 0x06 + case config = 0x07 + case disconnecting = 0x08 + case closed = 0x09 +} From 03f53038e8a9d0f3df54ec3b2a62b283250a05e4 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 02:15:33 -0400 Subject: [PATCH 04/10] Add HIDP connection information --- .../BluetoothLinux/HIDP/HIDPConnection.swift | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 Sources/BluetoothLinux/HIDP/HIDPConnection.swift diff --git a/Sources/BluetoothLinux/HIDP/HIDPConnection.swift b/Sources/BluetoothLinux/HIDP/HIDPConnection.swift new file mode 100644 index 0000000..0c50f0b --- /dev/null +++ b/Sources/BluetoothLinux/HIDP/HIDPConnection.swift @@ -0,0 +1,71 @@ +// +// HIDPConnection.swift +// BluetoothLinux +// + +import Foundation +import Bluetooth +import SystemPackage +import Socket + +/// HIDP connection information. +public struct HIDPConnection: Equatable, Hashable, Sendable { + + /// Address of the remote device. + public let address: BluetoothAddress + + /// Connection flags. + public let flags: HIDPConnectionFlag + + /// Connection state. + public let state: HIDPConnectionState + + /// Vendor identifier. + public let vendor: UInt16 + + /// Product identifier. + public let product: UInt16 + + /// Version number. + public let version: UInt16 + + /// Name of the device. + public let name: String +} + +internal extension HIDPConnection { + + init(_ bytes: CInterop.HIDPConnectionInformation) { + self.address = BluetoothAddress(littleEndian: bytes.address) + self.flags = HIDPConnectionFlag(rawValue: bytes.flags) + self.state = HIDPConnectionState(rawValue: bytes.state) ?? .unknown + self.vendor = bytes.vendor + self.product = bytes.product + self.version = bytes.version + self.name = String(hidpDeviceName: bytes.name) + } +} + +internal extension String { + + /// Decode from a fixed-size null-terminated device name buffer. + @usableFromInline + init(hidpDeviceName: CInterop.HIDPDeviceName) { + self = withUnsafeBytes(of: hidpDeviceName) { buffer in + String(decoding: buffer.prefix(while: { $0 != 0 }), as: UTF8.self) + } + } + + /// Encode as a fixed-size null-terminated device name buffer. + @usableFromInline + var hidpDeviceName: CInterop.HIDPDeviceName { + var name: CInterop.HIDPDeviceName = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + let utf8 = Array(self.utf8.prefix(127)) + withUnsafeMutableBytes(of: &name) { buffer in + for (index, byte) in utf8.enumerated() { + buffer[index] = byte + } + } + return name + } +} From a366f6bde08338b76bdd71bff72a2c40229ff136 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 02:15:33 -0400 Subject: [PATCH 05/10] Add HIDP add connection ioctl --- .../HIDP/IOCTL/HIDPAddConnection.swift | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 Sources/BluetoothLinux/HIDP/IOCTL/HIDPAddConnection.swift diff --git a/Sources/BluetoothLinux/HIDP/IOCTL/HIDPAddConnection.swift b/Sources/BluetoothLinux/HIDP/IOCTL/HIDPAddConnection.swift new file mode 100644 index 0000000..a9f6a20 --- /dev/null +++ b/Sources/BluetoothLinux/HIDP/IOCTL/HIDPAddConnection.swift @@ -0,0 +1,136 @@ +// +// HIDPAddConnection.swift +// BluetoothLinux +// + +import Foundation +import Bluetooth +import SystemPackage +import Socket + +public extension HIDPIO { + + /// HIDP Add Connection + /// + /// Bridges connected L2CAP control (PSM 17) and interrupt (PSM 19) sockets + /// into a kernel input device. + struct AddConnection: IOControlValue { + + @_alwaysEmitIntoClient + public static var id: HIDPIO { .addConnection } + + @usableFromInline + internal private(set) var bytes: CInterop.HIDPConnectionAddRequest + + /// HID report descriptor. + public let reportDescriptor: Data + + public init( + controlSocket: SocketDescriptor, + interruptSocket: SocketDescriptor, + flags: HIDPConnectionFlag = [], + parser: UInt16 = 0x0100, + country: UInt8 = 0, + subclass: UInt8 = 0, + vendor: UInt16 = 0, + product: UInt16 = 0, + version: UInt16 = 0, + name: String = "", + reportDescriptor: Data = Data(), + idleTimeout: UInt32 = 0 + ) { + precondition(reportDescriptor.count <= UInt16.max) + self.reportDescriptor = reportDescriptor + self.bytes = CInterop.HIDPConnectionAddRequest( + controlSocket: controlSocket.rawValue, + interruptSocket: interruptSocket.rawValue, + parser: parser, + reportDescriptorSize: UInt16(reportDescriptor.count), + reportDescriptor: nil, + country: country, + subclass: subclass, + vendor: vendor, + product: product, + version: version, + flags: flags.rawValue, + idleTimeout: idleTimeout, + name: name.hidpDeviceName + ) + } + + public mutating func withUnsafeMutablePointer(_ body: (UnsafeMutableRawPointer) throws -> (Result)) rethrows -> Result { + // keep the report descriptor buffer alive for the duration of the call + var descriptor = [UInt8](reportDescriptor) + var bytes = self.bytes + let result: Result = try descriptor.withUnsafeMutableBufferPointer { buffer in + bytes.reportDescriptor = buffer.baseAddress + return try Swift.withUnsafeMutableBytes(of: &bytes) { requestBuffer in + try body(requestBuffer.baseAddress!) + } + } + bytes.reportDescriptor = nil + self.bytes = bytes + return result + } + } +} + +public extension HIDPIO.AddConnection { + + @_alwaysEmitIntoClient + var controlSocket: SocketDescriptor { + return .init(rawValue: bytes.controlSocket) + } + + @_alwaysEmitIntoClient + var interruptSocket: SocketDescriptor { + return .init(rawValue: bytes.interruptSocket) + } + + @_alwaysEmitIntoClient + var flags: HIDPConnectionFlag { + return .init(rawValue: bytes.flags) + } + + /// Name of the device. + var name: String { + return String(hidpDeviceName: bytes.name) + } +} + +// MARK: - File Descriptor + +internal extension SocketDescriptor { + + @usableFromInline + func hidpAddConnection( + controlSocket: SocketDescriptor, + interruptSocket: SocketDescriptor, + flags: HIDPConnectionFlag = [], + parser: UInt16 = 0x0100, + country: UInt8 = 0, + subclass: UInt8 = 0, + vendor: UInt16 = 0, + product: UInt16 = 0, + version: UInt16 = 0, + name: String = "", + reportDescriptor: Data = Data(), + idleTimeout: UInt32 = 0 + ) throws { + var request = HIDPIO.AddConnection( + controlSocket: controlSocket, + interruptSocket: interruptSocket, + flags: flags, + parser: parser, + country: country, + subclass: subclass, + vendor: vendor, + product: product, + version: version, + name: name, + reportDescriptor: reportDescriptor, + idleTimeout: idleTimeout + ) + try inputOutput(&request) + } +} From 07d60997661601da7837ad1c6f7324ed10ec2336 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 02:15:33 -0400 Subject: [PATCH 06/10] Add HIDP remove connection ioctl --- .../HIDP/IOCTL/HIDPRemoveConnection.swift | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 Sources/BluetoothLinux/HIDP/IOCTL/HIDPRemoveConnection.swift diff --git a/Sources/BluetoothLinux/HIDP/IOCTL/HIDPRemoveConnection.swift b/Sources/BluetoothLinux/HIDP/IOCTL/HIDPRemoveConnection.swift new file mode 100644 index 0000000..2c5e570 --- /dev/null +++ b/Sources/BluetoothLinux/HIDP/IOCTL/HIDPRemoveConnection.swift @@ -0,0 +1,75 @@ +// +// HIDPRemoveConnection.swift +// BluetoothLinux +// + +import Bluetooth +import SystemPackage +import Socket + +public extension HIDPIO { + + /// HIDP Remove Connection + /// + /// Destroys the kernel session (and input device) for the specified remote device. + struct RemoveConnection: Equatable, Hashable, IOControlValue { + + @_alwaysEmitIntoClient + public static var id: HIDPIO { .removeConnection } + + @usableFromInline + internal private(set) var bytes: CInterop.HIDPConnectionDeleteRequest + + @usableFromInline + internal init(_ bytes: CInterop.HIDPConnectionDeleteRequest) { + self.bytes = bytes + } + + public init( + destination: BluetoothAddress, + flags: HIDPConnectionFlag = [] + ) { + self.init(CInterop.HIDPConnectionDeleteRequest( + address: destination.littleEndian, + flags: flags.rawValue) + ) + } + + @_alwaysEmitIntoClient + public mutating func withUnsafeMutablePointer(_ body: (UnsafeMutableRawPointer) throws -> (Result)) rethrows -> Result { + try Swift.withUnsafeMutableBytes(of: &bytes) { buffer in + try body(buffer.baseAddress!) + } + } + } +} + +public extension HIDPIO.RemoveConnection { + + @_alwaysEmitIntoClient + var destination: BluetoothAddress { + return BluetoothAddress(littleEndian: bytes.address) + } + + @_alwaysEmitIntoClient + var flags: HIDPConnectionFlag { + return .init(rawValue: bytes.flags) + } +} + +// MARK: - File Descriptor + +internal extension SocketDescriptor { + + @usableFromInline + func hidpRemoveConnection( + destination: BluetoothAddress, + flags: HIDPConnectionFlag = [] + ) throws { + var request = HIDPIO.RemoveConnection( + destination: destination, + flags: flags + ) + try inputOutput(&request) + } +} From c59b233326fb260da74e5bf76bd6675ba03f892f Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 02:15:33 -0400 Subject: [PATCH 07/10] Add HIDP connection list ioctl --- .../HIDP/IOCTL/HIDPGetConnectionList.swift | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 Sources/BluetoothLinux/HIDP/IOCTL/HIDPGetConnectionList.swift diff --git a/Sources/BluetoothLinux/HIDP/IOCTL/HIDPGetConnectionList.swift b/Sources/BluetoothLinux/HIDP/IOCTL/HIDPGetConnectionList.swift new file mode 100644 index 0000000..a643a75 --- /dev/null +++ b/Sources/BluetoothLinux/HIDP/IOCTL/HIDPGetConnectionList.swift @@ -0,0 +1,73 @@ +// +// HIDPGetConnectionList.swift +// BluetoothLinux +// + +import Bluetooth +import SystemPackage +import Socket + +public extension HIDPIO { + + /// HIDP Get Connection List + struct GetConnectionList: IOControlValue { + + @_alwaysEmitIntoClient + public static var id: HIDPIO { .getConnectionList } + + @_alwaysEmitIntoClient + public static var maxLimit: Int { 256 } + + public var limit: Int + + public private(set) var response: [HIDPConnection] + + public init(limit: Int = Self.maxLimit) { + precondition(limit > 0, "Must request at least one connection") + precondition(limit <= Self.maxLimit, "Only \(Self.maxLimit) maximum connections is allowed") + self.limit = limit + self.response = [] + } + + public mutating func withUnsafeMutablePointer(_ body: (UnsafeMutableRawPointer) throws -> (Result)) rethrows -> Result { + + // `struct hidp_connlist_req` embeds a pointer to a caller-allocated + // `hidp_conninfo` array rather than a flexible array member. + let limit = self.limit + var connections = [CInterop.HIDPConnectionInformation]( + repeating: CInterop.HIDPConnectionInformation(), + count: limit + ) + var count: UInt32 = 0 + let result: Result = try connections.withUnsafeMutableBufferPointer { buffer in + var request = CInterop.HIDPConnectionListRequest( + count: UInt32(limit), + connections: buffer.baseAddress + ) + let result = try Swift.withUnsafeMutableBytes(of: &request) { requestBuffer in + try body(requestBuffer.baseAddress!) + } + count = request.count + return result + } + self.response = connections + .prefix(Int(min(count, UInt32(limit)))) + .map { HIDPConnection($0) } + return result + } + } +} + +// MARK: - File Descriptor + +internal extension SocketDescriptor { + + @usableFromInline + func hidpConnectionList( + limit: Int = HIDPIO.GetConnectionList.maxLimit + ) throws -> [HIDPConnection] { + var request = HIDPIO.GetConnectionList(limit: limit) + try inputOutput(&request) + return request.response + } +} From f8ed13847d3844ebeebb6f05568e21e7df519c10 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 02:15:33 -0400 Subject: [PATCH 08/10] Add HIDP connection information ioctl --- .../IOCTL/HIDPGetConnectionInformation.swift | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 Sources/BluetoothLinux/HIDP/IOCTL/HIDPGetConnectionInformation.swift diff --git a/Sources/BluetoothLinux/HIDP/IOCTL/HIDPGetConnectionInformation.swift b/Sources/BluetoothLinux/HIDP/IOCTL/HIDPGetConnectionInformation.swift new file mode 100644 index 0000000..e869f97 --- /dev/null +++ b/Sources/BluetoothLinux/HIDP/IOCTL/HIDPGetConnectionInformation.swift @@ -0,0 +1,53 @@ +// +// HIDPGetConnectionInformation.swift +// BluetoothLinux +// + +import Bluetooth +import SystemPackage +import Socket + +public extension HIDPIO { + + /// HIDP Get Connection Information + struct GetConnectionInformation: IOControlValue { + + @_alwaysEmitIntoClient + public static var id: HIDPIO { .getConnectionInfo } + + @usableFromInline + internal private(set) var bytes: CInterop.HIDPConnectionInformation + + public init(destination: BluetoothAddress) { + self.bytes = CInterop.HIDPConnectionInformation( + address: destination.littleEndian + ) + } + + /// The connection information returned by the kernel. + public var response: HIDPConnection { + return HIDPConnection(bytes) + } + + @_alwaysEmitIntoClient + public mutating func withUnsafeMutablePointer(_ body: (UnsafeMutableRawPointer) throws -> (Result)) rethrows -> Result { + try Swift.withUnsafeMutableBytes(of: &bytes) { buffer in + try body(buffer.baseAddress!) + } + } + } +} + +// MARK: - File Descriptor + +internal extension SocketDescriptor { + + @usableFromInline + func hidpConnectionInformation( + for destination: BluetoothAddress + ) throws -> HIDPConnection { + var request = HIDPIO.GetConnectionInformation(destination: destination) + try inputOutput(&request) + return request.response + } +} From d0b8da47e705bbe6ca4677ec234f088cc061117f Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 02:15:33 -0400 Subject: [PATCH 09/10] Add HIDP control socket --- Sources/BluetoothLinux/HIDP/HIDP.swift | 93 +++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 2 deletions(-) diff --git a/Sources/BluetoothLinux/HIDP/HIDP.swift b/Sources/BluetoothLinux/HIDP/HIDP.swift index ec99e72..be4bb75 100644 --- a/Sources/BluetoothLinux/HIDP/HIDP.swift +++ b/Sources/BluetoothLinux/HIDP/HIDP.swift @@ -1,10 +1,99 @@ // // HIDP.swift -// +// BluetoothLinux // -// Created by Alsey Coleman Miller on 16/10/21. +// Human Interface Device Protocol control socket. // +import Foundation +import Bluetooth import SystemPackage +import Socket +/// HIDP control socket. +/// +/// Manages kernel HID protocol sessions, which bridge connected L2CAP +/// control (PSM 17) and interrupt (PSM 19) sockets into kernel input devices. +public struct HIDPSocket: Sendable { + // MARK: - Properties + + @usableFromInline + internal let fileDescriptor: SocketDescriptor + + // MARK: - Initialization + + /// Open a HIDP control socket. + public init() throws(Errno) { + self.fileDescriptor = try .bluetooth(.hidp, flags: [.closeOnExec]) + } + + // MARK: - Methods + + /// Close the control socket. + /// + /// Established sessions are not affected. + public func close() { + try? fileDescriptor.close() + } + + /// Bridge connected L2CAP control and interrupt sockets into a kernel input device. + /// + /// The L2CAP sockets must be connected to the remote device on the HID control (PSM 17) + /// and HID interrupt (PSM 19) channels. The report descriptor and device identity are + /// typically read from the device's service record. + public func addConnection( + control: L2CAPSocket, + interrupt: L2CAPSocket, + flags: HIDPConnectionFlag = [], + parser: UInt16 = 0x0100, + country: UInt8 = 0, + subclass: UInt8 = 0, + vendor: UInt16 = 0, + product: UInt16 = 0, + version: UInt16 = 0, + name: String = "", + reportDescriptor: Data = Data(), + idleTimeout: UInt32 = 0 + ) throws { + try fileDescriptor.hidpAddConnection( + controlSocket: control.fileDescriptor, + interruptSocket: interrupt.fileDescriptor, + flags: flags, + parser: parser, + country: country, + subclass: subclass, + vendor: vendor, + product: product, + version: version, + name: name, + reportDescriptor: reportDescriptor, + idleTimeout: idleTimeout + ) + } + + /// Destroy the session (and input device) for the specified remote device. + public func removeConnection( + destination: BluetoothAddress, + flags: HIDPConnectionFlag = [] + ) throws { + try fileDescriptor.hidpRemoveConnection( + destination: destination, + flags: flags + ) + } + + /// List the active sessions. + public func connections( + limit: Int = HIDPIO.GetConnectionList.maxLimit + ) throws -> [HIDPConnection] { + try fileDescriptor.hidpConnectionList(limit: limit) + } + + /// Read information for the session with the specified remote device. + public func connectionInformation( + for destination: BluetoothAddress + ) throws -> HIDPConnection { + try fileDescriptor.hidpConnectionInformation(for: destination) + } +} From 9153b595bdf47ecd7d106c21b383d4bce5d23ede Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 02:15:33 -0400 Subject: [PATCH 10/10] Add HIDP tests --- Tests/BluetoothLinuxTests/HIDPTests.swift | 167 ++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 Tests/BluetoothLinuxTests/HIDPTests.swift diff --git a/Tests/BluetoothLinuxTests/HIDPTests.swift b/Tests/BluetoothLinuxTests/HIDPTests.swift new file mode 100644 index 0000000..57dc8ca --- /dev/null +++ b/Tests/BluetoothLinuxTests/HIDPTests.swift @@ -0,0 +1,167 @@ +// +// HIDPTests.swift +// BluetoothLinuxTests +// + +#if ENABLE_MOCKING +import Foundation +import XCTest +import Bluetooth +import SystemPackage +import Socket +@testable import BluetoothLinux +#if canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif + +final class HIDPTests: XCTestCase { + + /// A fake file descriptor; mocked syscalls never reach the kernel. + private let fileDescriptor = SocketDescriptor(rawValue: 3) + + func testIORawValues() { + // read-direction requests are sign-extended, matching the C `ioctl` request argument + XCTAssertEqual(HIDPIO.addConnection.rawValue, 0x400448C8) + XCTAssertEqual(HIDPIO.removeConnection.rawValue, 0x400448C9) + XCTAssertEqual(HIDPIO.getConnectionList.rawValue, UInt(bitPattern: Int(Int32(bitPattern: 0x800448D2)))) + XCTAssertEqual(HIDPIO.getConnectionInfo.rawValue, UInt(bitPattern: Int(Int32(bitPattern: 0x800448D3)))) + for value in HIDPIO.allCases { + XCTAssertEqual(HIDPIO(rawValue: value.rawValue), value) + } + } + + func testRequestLayout() { + // must match the kernel's C struct layouts + XCTAssertEqual(MemoryLayout.size, 168) + XCTAssertEqual(MemoryLayout.stride, 168) + XCTAssertEqual(MemoryLayout.size, 12) + XCTAssertEqual(MemoryLayout.size, 148) + XCTAssertEqual(MemoryLayout.size, 16) + } + + func testDeviceName() { + let name = "Wireless Keyboard".hidpDeviceName + XCTAssertEqual(String(hidpDeviceName: name), "Wireless Keyboard") + // truncated to 127 bytes plus null terminator + let long = String(repeating: "a", count: 200).hidpDeviceName + XCTAssertEqual(String(hidpDeviceName: long).count, 127) + } + + func testAddConnectionFakedKernelReply() throws { + try MockingDriver.withMockingEnabled { driver in + let descriptor = Data([0x05, 0x01, 0x09, 0x06]) // partial keyboard descriptor + driver.ioctlHandler = { fd, request, pointer in + XCTAssertEqual(request, HIDPIO.addConnection.rawValue) + guard let pointer else { + XCTFail("Expected a request buffer for HIDPCONNADD") + errno = EINVAL + return -1 + } + let request = pointer.assumingMemoryBound(to: CInterop.HIDPConnectionAddRequest.self) + XCTAssertEqual(request.pointee.controlSocket, 7) + XCTAssertEqual(request.pointee.interruptSocket, 8) + XCTAssertEqual(request.pointee.parser, 0x0100) + XCTAssertEqual(request.pointee.vendor, 0x05AC) + XCTAssertEqual(request.pointee.reportDescriptorSize, 4) + guard let reportDescriptor = request.pointee.reportDescriptor else { + XCTFail("Expected a report descriptor buffer") + errno = EINVAL + return -1 + } + XCTAssertEqual(Data(bytes: reportDescriptor, count: 4), descriptor) + XCTAssertEqual(String(hidpDeviceName: request.pointee.name), "Test Keyboard") + return 0 + } + try fileDescriptor.hidpAddConnection( + controlSocket: SocketDescriptor(rawValue: 7), + interruptSocket: SocketDescriptor(rawValue: 8), + vendor: 0x05AC, + name: "Test Keyboard", + reportDescriptor: descriptor + ) + } + } + + func testRemoveConnectionIsTraced() throws { + try MockingDriver.withMockingEnabled { driver in + try fileDescriptor.hidpRemoveConnection( + destination: BluetoothAddress(bytes: (1, 2, 3, 4, 5, 6)) + ) + XCTAssertEqual( + driver.trace.dequeue(), + Trace.Entry(name: "ioctl", [ + fileDescriptor.rawValue, + HIDPIO.removeConnection.rawValue + ]) + ) + XCTAssertTrue(driver.trace.isEmpty) + } + } + + func testConnectionListFakedKernelReply() throws { + try MockingDriver.withMockingEnabled { driver in + let expected = CInterop.HIDPConnectionInformation( + address: BluetoothAddress(bytes: (6, 5, 4, 3, 2, 1)), + flags: HIDPConnectionFlag.bootProtocolMode.rawValue, + state: HIDPConnectionState.connected.rawValue, + vendor: 0x05AC, + product: 0x022C, + version: 0x011B, + name: "Test Keyboard".hidpDeviceName + ) + driver.ioctlHandler = { fd, request, pointer in + XCTAssertEqual(request, HIDPIO.getConnectionList.rawValue) + guard let pointer else { + XCTFail("Expected a request buffer for HIDPGETCONNLIST") + errno = EINVAL + return -1 + } + let request = pointer.assumingMemoryBound(to: CInterop.HIDPConnectionListRequest.self) + guard let connections = request.pointee.connections else { + XCTFail("Expected a connection buffer") + errno = EINVAL + return -1 + } + XCTAssertGreaterThanOrEqual(request.pointee.count, 1) + connections[0] = expected + request.pointee.count = 1 + return 0 + } + let connections = try fileDescriptor.hidpConnectionList(limit: 8) + XCTAssertEqual(connections.count, 1) + XCTAssertEqual(connections[0].name, "Test Keyboard") + XCTAssertEqual(connections[0].flags, [.bootProtocolMode]) + XCTAssertEqual(connections[0].state, .connected) + XCTAssertEqual(connections[0].vendor, 0x05AC) + XCTAssertEqual(connections[0].product, 0x022C) + XCTAssertEqual(connections[0].address, BluetoothAddress(littleEndian: BluetoothAddress(bytes: (6, 5, 4, 3, 2, 1)))) + } + } + + func testConnectionInformationFakedKernelReply() throws { + try MockingDriver.withMockingEnabled { driver in + driver.ioctlHandler = { fd, request, pointer in + XCTAssertEqual(request, HIDPIO.getConnectionInfo.rawValue) + guard let pointer else { + XCTFail("Expected a request buffer for HIDPGETCONNINFO") + errno = EINVAL + return -1 + } + let request = pointer.assumingMemoryBound(to: CInterop.HIDPConnectionInformation.self) + request.pointee.state = HIDPConnectionState.connected.rawValue + request.pointee.vendor = 0x05AC + request.pointee.name = "Test Mouse".hidpDeviceName + return 0 + } + let connection = try fileDescriptor.hidpConnectionInformation( + for: BluetoothAddress(bytes: (1, 2, 3, 4, 5, 6)) + ) + XCTAssertEqual(connection.name, "Test Mouse") + XCTAssertEqual(connection.state, .connected) + XCTAssertEqual(connection.vendor, 0x05AC) + } + } +} +#endif