diff --git a/Sources/BluetoothLinux/Extensions/Integer.swift b/Sources/BluetoothLinux/Extensions/Integer.swift new file mode 100644 index 0000000..94263f1 --- /dev/null +++ b/Sources/BluetoothLinux/Extensions/Integer.swift @@ -0,0 +1,26 @@ +// +// Integer.swift +// BluetoothLinux +// + +internal extension UInt16 { + + init(bytes: (UInt8, UInt8)) { + self = unsafeBitCast(bytes, to: UInt16.self) + } + + var bytes: (UInt8, UInt8) { + unsafeBitCast(self, to: (UInt8, UInt8).self) + } +} + +internal extension UInt32 { + + init(bytes: (UInt8, UInt8, UInt8, UInt8)) { + self = unsafeBitCast(bytes, to: UInt32.self) + } + + var bytes: (UInt8, UInt8, UInt8, UInt8) { + unsafeBitCast(self, to: (UInt8, UInt8, UInt8, UInt8).self) + } +} diff --git a/Sources/BluetoothLinux/Management/ManagementCommands.swift b/Sources/BluetoothLinux/Management/ManagementCommands.swift new file mode 100644 index 0000000..fcc6d70 --- /dev/null +++ b/Sources/BluetoothLinux/Management/ManagementCommands.swift @@ -0,0 +1,191 @@ +// +// ManagementCommands.swift +// BluetoothLinux +// + +import Foundation +import Bluetooth +import BluetoothHCI + +/// Bluetooth Management interface version information. +public struct ManagementVersion: Equatable, Hashable, Sendable { + + /// Interface version. + public let version: UInt8 + + /// Interface revision. + public let revision: UInt16 +} + +/// Discoverable mode. +public enum ManagementDiscoverableMode: UInt8, Sendable, CaseIterable { + + case disabled = 0x00 + case general = 0x01 + case limited = 0x02 +} + +/// Advertising mode. +public enum ManagementAdvertisingMode: UInt8, Sendable, CaseIterable { + + case disabled = 0x00 + case enabled = 0x01 + + /// Advertise as connectable even when not connectable. + case connectable = 0x02 +} + +public extension ManagementSocket { + + /// Read Management Version Information + func readVersion() async throws -> ManagementVersion { + let response = try await send(.readVersion) + guard response.count >= 3 else { + throw BluetoothHostControllerError.garbageResponse(response) + } + let bytes = Array(response) + return ManagementVersion( + version: bytes[0], + revision: UInt16(littleEndian: UInt16(bytes: (bytes[1], bytes[2]))) + ) + } + + /// Read Controller Index List + func readControllerIndexList() async throws -> [HostController.ID] { + let response = try await send(.readIndexList) + guard response.count >= 2 else { + throw BluetoothHostControllerError.garbageResponse(response) + } + let bytes = Array(response) + let count = Int(UInt16(littleEndian: UInt16(bytes: (bytes[0], bytes[1])))) + guard response.count >= 2 + (count * 2) else { + throw BluetoothHostControllerError.garbageResponse(response) + } + return (0 ..< count).map { index in + let offset = 2 + (index * 2) + return HostController.ID( + rawValue: UInt16(littleEndian: UInt16(bytes: (bytes[offset], bytes[offset + 1]))) + ) + } + } + + /// Read Controller Information + func readControllerInformation(for index: HostController.ID) async throws -> ManagementControllerInformation { + let response = try await send(.readControllerInformation, index: index) + guard let information = ManagementControllerInformation(data: response) else { + throw BluetoothHostControllerError.garbageResponse(response) + } + return information + } + + /// Set Powered + @discardableResult + func setPowered(_ isEnabled: Bool, for index: HostController.ID) async throws -> ManagementSettings { + try await setMode(.setPowered, isEnabled, for: index) + } + + /// Set Connectable + @discardableResult + func setConnectable(_ isEnabled: Bool, for index: HostController.ID) async throws -> ManagementSettings { + try await setMode(.setConnectable, isEnabled, for: index) + } + + /// Set Fast Connectable + @discardableResult + func setFastConnectable(_ isEnabled: Bool, for index: HostController.ID) async throws -> ManagementSettings { + try await setMode(.setFastConnectable, isEnabled, for: index) + } + + /// Set Bondable + @discardableResult + func setBondable(_ isEnabled: Bool, for index: HostController.ID) async throws -> ManagementSettings { + try await setMode(.setBondable, isEnabled, for: index) + } + + /// Set Link Security + @discardableResult + func setLinkSecurity(_ isEnabled: Bool, for index: HostController.ID) async throws -> ManagementSettings { + try await setMode(.setLinkSecurity, isEnabled, for: index) + } + + /// Set Secure Simple Pairing + @discardableResult + func setSecureSimplePairing(_ isEnabled: Bool, for index: HostController.ID) async throws -> ManagementSettings { + try await setMode(.setSecureSimplePairing, isEnabled, for: index) + } + + /// Set Low Energy + @discardableResult + func setLowEnergy(_ isEnabled: Bool, for index: HostController.ID) async throws -> ManagementSettings { + try await setMode(.setLowEnergy, isEnabled, for: index) + } + + /// Set Discoverable + /// + /// - Parameter mode: The discoverable mode to set. + /// - Parameter timeout: Duration in seconds before discoverable mode is disabled again, + /// or `0` to remain discoverable indefinitely. + @discardableResult + func setDiscoverable( + _ mode: ManagementDiscoverableMode, + timeout: UInt16 = 0, + for index: HostController.ID + ) async throws -> ManagementSettings { + let timeoutBytes = timeout.littleEndian.bytes + let parameters = Data([mode.rawValue, timeoutBytes.0, timeoutBytes.1]) + let response = try await send(.setDiscoverable, index: index, parameters: parameters) + return try Self.settings(from: response) + } + + /// Set Advertising + @discardableResult + func setAdvertising( + _ mode: ManagementAdvertisingMode, + for index: HostController.ID + ) async throws -> ManagementSettings { + let response = try await send(.setAdvertising, index: index, parameters: Data([mode.rawValue])) + return try Self.settings(from: response) + } + + /// Set Local Name + /// + /// - Parameter name: The controller name (truncated to fit). + /// - Parameter shortName: The short name used when advertising space is limited (truncated to fit). + func setLocalName( + _ name: String, + shortName: String = "", + for index: HostController.ID + ) async throws { + var parameters = Data(capacity: ManagementControllerInformation.maximumNameLength + ManagementControllerInformation.maximumShortNameLength) + parameters.append(Self.fixedLengthString(name, length: ManagementControllerInformation.maximumNameLength)) + parameters.append(Self.fixedLengthString(shortName, length: ManagementControllerInformation.maximumShortNameLength)) + try await send(.setLocalName, index: index, parameters: parameters) + } + + // MARK: - Internal + + internal func setMode( + _ opcode: ManagementOpcode, + _ isEnabled: Bool, + for index: HostController.ID + ) async throws -> ManagementSettings { + let response = try await send(opcode, index: index, parameters: Data([isEnabled ? 0x01 : 0x00])) + return try Self.settings(from: response) + } + + internal static func settings(from response: Data) throws -> ManagementSettings { + guard response.count >= 4 else { + throw BluetoothHostControllerError.garbageResponse(response) + } + let bytes = Array(response) + return ManagementSettings( + rawValue: UInt32(littleEndian: UInt32(bytes: (bytes[0], bytes[1], bytes[2], bytes[3]))) + ) + } + + internal static func fixedLengthString(_ string: String, length: Int) -> Data { + var data = Data(string.utf8.prefix(length - 1)) + data.append(contentsOf: repeatElement(0, count: length - data.count)) + return data + } +} diff --git a/Sources/BluetoothLinux/Management/ManagementControllerInformation.swift b/Sources/BluetoothLinux/Management/ManagementControllerInformation.swift new file mode 100644 index 0000000..e15f96a --- /dev/null +++ b/Sources/BluetoothLinux/Management/ManagementControllerInformation.swift @@ -0,0 +1,107 @@ +// +// ManagementControllerInformation.swift +// BluetoothLinux +// + +import Foundation +import Bluetooth + +/// Bluetooth Management interface controller information. +/// +/// Return parameters of the Read Controller Information command. +public struct ManagementControllerInformation: Equatable, Hashable, Sendable { + + /// Length of the encoded structure in bytes. + public static var length: Int { 6 + 1 + 2 + 4 + 4 + 3 + Self.maximumNameLength + Self.maximumShortNameLength } + + /// Maximum length of the controller name in bytes, including the null terminator. + public static var maximumNameLength: Int { 249 } + + /// Maximum length of the controller short name in bytes, including the null terminator. + public static var maximumShortNameLength: Int { 11 } + + /// Controller address. + public let address: BluetoothAddress + + /// Bluetooth core specification version. + public let version: UInt8 + + /// Manufacturer identifier. + public let manufacturer: UInt16 + + /// Settings the controller supports. + public let supportedSettings: ManagementSettings + + /// Settings currently active on the controller. + public let currentSettings: ManagementSettings + + /// Class of device. + public let classOfDevice: (UInt8, UInt8, UInt8) + + /// Controller name. + public let name: String + + /// Controller short name. + public let shortName: String + + public static func == (lhs: ManagementControllerInformation, rhs: ManagementControllerInformation) -> Bool { + lhs.address == rhs.address + && lhs.version == rhs.version + && lhs.manufacturer == rhs.manufacturer + && lhs.supportedSettings == rhs.supportedSettings + && lhs.currentSettings == rhs.currentSettings + && lhs.classOfDevice == rhs.classOfDevice + && lhs.name == rhs.name + && lhs.shortName == rhs.shortName + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(address) + hasher.combine(version) + hasher.combine(manufacturer) + hasher.combine(supportedSettings) + hasher.combine(currentSettings) + hasher.combine(classOfDevice.0) + hasher.combine(classOfDevice.1) + hasher.combine(classOfDevice.2) + hasher.combine(name) + hasher.combine(shortName) + } +} + +public extension ManagementControllerInformation { + + init?(data: Data) { + guard data.count >= Self.length else { + return nil + } + let bytes = Array(data) + self.address = BluetoothAddress( + littleEndian: BluetoothAddress( + bytes: (bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]) + ) + ) + self.version = bytes[6] + self.manufacturer = UInt16(littleEndian: UInt16(bytes: (bytes[7], bytes[8]))) + self.supportedSettings = ManagementSettings( + rawValue: UInt32(littleEndian: UInt32(bytes: (bytes[9], bytes[10], bytes[11], bytes[12]))) + ) + self.currentSettings = ManagementSettings( + rawValue: UInt32(littleEndian: UInt32(bytes: (bytes[13], bytes[14], bytes[15], bytes[16]))) + ) + self.classOfDevice = (bytes[17], bytes[18], bytes[19]) + let nameBytes = bytes[20 ..< 20 + Self.maximumNameLength] + let shortNameBytes = bytes[20 + Self.maximumNameLength ..< Self.length] + self.name = String(cString: nameBytes) + self.shortName = String(cString: shortNameBytes) + } +} + +internal extension String { + + /// Initialize from a fixed-size null-terminated C string buffer. + init(cString bytes: C) where C.Element == UInt8 { + let characters = bytes.prefix(while: { $0 != 0 }) + self.init(decoding: characters, as: UTF8.self) + } +} diff --git a/Sources/BluetoothLinux/Management/ManagementEvent.swift b/Sources/BluetoothLinux/Management/ManagementEvent.swift new file mode 100644 index 0000000..cabb23d --- /dev/null +++ b/Sources/BluetoothLinux/Management/ManagementEvent.swift @@ -0,0 +1,180 @@ +// +// ManagementEvent.swift +// BluetoothLinux +// + +/// Bluetooth Management interface event code. +/// +/// Events received from the kernel over an HCI control channel (`HCI_CHANNEL_CONTROL`) socket. +@frozen +public struct ManagementEvent: RawRepresentable, Equatable, Hashable, Sendable { + + public let rawValue: UInt16 + + public init(rawValue: UInt16) { + self.rawValue = rawValue + } +} + +public extension ManagementEvent { + + /// Command Complete + static var commandComplete: ManagementEvent { 0x0001 } + + /// Command Status + static var commandStatus: ManagementEvent { 0x0002 } + + /// Controller Error + static var controllerError: ManagementEvent { 0x0003 } + + /// Index Added + static var indexAdded: ManagementEvent { 0x0004 } + + /// Index Removed + static var indexRemoved: ManagementEvent { 0x0005 } + + /// New Settings + static var newSettings: ManagementEvent { 0x0006 } + + /// Class Of Device Changed + static var classOfDeviceChanged: ManagementEvent { 0x0007 } + + /// Local Name Changed + static var localNameChanged: ManagementEvent { 0x0008 } + + /// New Link Key + static var newLinkKey: ManagementEvent { 0x0009 } + + /// New Long Term Key + static var newLongTermKey: ManagementEvent { 0x000A } + + /// Device Connected + static var deviceConnected: ManagementEvent { 0x000B } + + /// Device Disconnected + static var deviceDisconnected: ManagementEvent { 0x000C } + + /// Connect Failed + static var connectFailed: ManagementEvent { 0x000D } + + /// PIN Code Request + static var pinCodeRequest: ManagementEvent { 0x000E } + + /// User Confirmation Request + static var userConfirmationRequest: ManagementEvent { 0x000F } + + /// User Passkey Request + static var userPasskeyRequest: ManagementEvent { 0x0010 } + + /// Authentication Failed + static var authenticationFailed: ManagementEvent { 0x0011 } + + /// Device Found + static var deviceFound: ManagementEvent { 0x0012 } + + /// Discovering + static var discovering: ManagementEvent { 0x0013 } + + /// Device Blocked + static var deviceBlocked: ManagementEvent { 0x0014 } + + /// Device Unblocked + static var deviceUnblocked: ManagementEvent { 0x0015 } + + /// Device Unpaired + static var deviceUnpaired: ManagementEvent { 0x0016 } + + /// Passkey Notify + static var passkeyNotify: ManagementEvent { 0x0017 } + + /// New Identity Resolving Key + static var newIdentityResolvingKey: ManagementEvent { 0x0018 } + + /// New Signature Resolving Key + static var newSignatureResolvingKey: ManagementEvent { 0x0019 } + + /// Device Added + static var deviceAdded: ManagementEvent { 0x001A } + + /// Device Removed + static var deviceRemoved: ManagementEvent { 0x001B } + + /// New Connection Parameters + static var newConnectionParameters: ManagementEvent { 0x001C } + + /// Unconfigured Index Added + static var unconfiguredIndexAdded: ManagementEvent { 0x001D } + + /// Unconfigured Index Removed + static var unconfiguredIndexRemoved: ManagementEvent { 0x001E } + + /// New Configuration Options + static var newConfigurationOptions: ManagementEvent { 0x001F } + + /// Extended Index Added + static var extendedIndexAdded: ManagementEvent { 0x0020 } + + /// Extended Index Removed + static var extendedIndexRemoved: ManagementEvent { 0x0021 } + + /// Local Out Of Band Data Updated + static var localOutOfBandDataUpdated: ManagementEvent { 0x0022 } + + /// Advertising Added + static var advertisingAdded: ManagementEvent { 0x0023 } + + /// Advertising Removed + static var advertisingRemoved: ManagementEvent { 0x0024 } + + /// Extended Controller Information Changed + static var extendedControllerInformationChanged: ManagementEvent { 0x0025 } + + /// PHY Configuration Changed + static var phyConfigurationChanged: ManagementEvent { 0x0026 } + + /// Experimental Feature Changed + static var experimentalFeatureChanged: ManagementEvent { 0x0027 } + + /// Default System Configuration Changed + static var defaultSystemConfigurationChanged: ManagementEvent { 0x0028 } + + /// Default Runtime Configuration Changed + static var defaultRuntimeConfigurationChanged: ManagementEvent { 0x0029 } + + /// Device Flags Changed + static var deviceFlagsChanged: ManagementEvent { 0x002A } + + /// Advertisement Monitor Added + static var advertisementMonitorAdded: ManagementEvent { 0x002B } + + /// Advertisement Monitor Removed + static var advertisementMonitorRemoved: ManagementEvent { 0x002C } + + /// Controller Suspend + static var controllerSuspend: ManagementEvent { 0x002D } + + /// Controller Resume + static var controllerResume: ManagementEvent { 0x002E } +} + +// MARK: - ExpressibleByIntegerLiteral + +extension ManagementEvent: ExpressibleByIntegerLiteral { + + public init(integerLiteral value: UInt16) { + self.init(rawValue: value) + } +} + +// MARK: - CustomStringConvertible + +extension ManagementEvent: CustomStringConvertible, CustomDebugStringConvertible { + + public var description: String { + "0x" + String(rawValue, radix: 16, uppercase: true).padded(to: 4) + } + + public var debugDescription: String { + description + } +} diff --git a/Sources/BluetoothLinux/Management/ManagementOpcode.swift b/Sources/BluetoothLinux/Management/ManagementOpcode.swift new file mode 100644 index 0000000..06b6707 --- /dev/null +++ b/Sources/BluetoothLinux/Management/ManagementOpcode.swift @@ -0,0 +1,262 @@ +// +// ManagementOpcode.swift +// BluetoothLinux +// + +/// Bluetooth Management interface command opcode. +/// +/// Commands sent to the kernel over an HCI control channel (`HCI_CHANNEL_CONTROL`) socket. +@frozen +public struct ManagementOpcode: RawRepresentable, Equatable, Hashable, Sendable { + + public let rawValue: UInt16 + + public init(rawValue: UInt16) { + self.rawValue = rawValue + } +} + +public extension ManagementOpcode { + + /// Read Management Version Information + static var readVersion: ManagementOpcode { 0x0001 } + + /// Read Management Supported Commands + static var readCommands: ManagementOpcode { 0x0002 } + + /// Read Controller Index List + static var readIndexList: ManagementOpcode { 0x0003 } + + /// Read Controller Information + static var readControllerInformation: ManagementOpcode { 0x0004 } + + /// Set Powered + static var setPowered: ManagementOpcode { 0x0005 } + + /// Set Discoverable + static var setDiscoverable: ManagementOpcode { 0x0006 } + + /// Set Connectable + static var setConnectable: ManagementOpcode { 0x0007 } + + /// Set Fast Connectable + static var setFastConnectable: ManagementOpcode { 0x0008 } + + /// Set Bondable + static var setBondable: ManagementOpcode { 0x0009 } + + /// Set Link Security + static var setLinkSecurity: ManagementOpcode { 0x000A } + + /// Set Secure Simple Pairing + static var setSecureSimplePairing: ManagementOpcode { 0x000B } + + /// Set High Speed + static var setHighSpeed: ManagementOpcode { 0x000C } + + /// Set Low Energy + static var setLowEnergy: ManagementOpcode { 0x000D } + + /// Set Device Class + static var setDeviceClass: ManagementOpcode { 0x000E } + + /// Set Local Name + static var setLocalName: ManagementOpcode { 0x000F } + + /// Add UUID + static var addUUID: ManagementOpcode { 0x0010 } + + /// Remove UUID + static var removeUUID: ManagementOpcode { 0x0011 } + + /// Load Link Keys + static var loadLinkKeys: ManagementOpcode { 0x0012 } + + /// Load Long Term Keys + static var loadLongTermKeys: ManagementOpcode { 0x0013 } + + /// Disconnect + static var disconnect: ManagementOpcode { 0x0014 } + + /// Get Connections + static var getConnections: ManagementOpcode { 0x0015 } + + /// PIN Code Reply + static var pinCodeReply: ManagementOpcode { 0x0016 } + + /// PIN Code Negative Reply + static var pinCodeNegativeReply: ManagementOpcode { 0x0017 } + + /// Set IO Capability + static var setIOCapability: ManagementOpcode { 0x0018 } + + /// Pair Device + static var pairDevice: ManagementOpcode { 0x0019 } + + /// Cancel Pair Device + static var cancelPairDevice: ManagementOpcode { 0x001A } + + /// Unpair Device + static var unpairDevice: ManagementOpcode { 0x001B } + + /// User Confirmation Reply + static var userConfirmationReply: ManagementOpcode { 0x001C } + + /// User Confirmation Negative Reply + static var userConfirmationNegativeReply: ManagementOpcode { 0x001D } + + /// User Passkey Reply + static var userPasskeyReply: ManagementOpcode { 0x001E } + + /// User Passkey Negative Reply + static var userPasskeyNegativeReply: ManagementOpcode { 0x001F } + + /// Read Local Out Of Band Data + static var readLocalOutOfBandData: ManagementOpcode { 0x0020 } + + /// Add Remote Out Of Band Data + static var addRemoteOutOfBandData: ManagementOpcode { 0x0021 } + + /// Remove Remote Out Of Band Data + static var removeRemoteOutOfBandData: ManagementOpcode { 0x0022 } + + /// Start Discovery + static var startDiscovery: ManagementOpcode { 0x0023 } + + /// Stop Discovery + static var stopDiscovery: ManagementOpcode { 0x0024 } + + /// Confirm Name + static var confirmName: ManagementOpcode { 0x0025 } + + /// Block Device + static var blockDevice: ManagementOpcode { 0x0026 } + + /// Unblock Device + static var unblockDevice: ManagementOpcode { 0x0027 } + + /// Set Device ID + static var setDeviceID: ManagementOpcode { 0x0028 } + + /// Set Advertising + static var setAdvertising: ManagementOpcode { 0x0029 } + + /// Set BR/EDR + static var setBREDR: ManagementOpcode { 0x002A } + + /// Set Static Address + static var setStaticAddress: ManagementOpcode { 0x002B } + + /// Set Scan Parameters + static var setScanParameters: ManagementOpcode { 0x002C } + + /// Set Secure Connections + static var setSecureConnections: ManagementOpcode { 0x002D } + + /// Set Debug Keys + static var setDebugKeys: ManagementOpcode { 0x002E } + + /// Set Privacy + static var setPrivacy: ManagementOpcode { 0x002F } + + /// Load Identity Resolving Keys + static var loadIdentityResolvingKeys: ManagementOpcode { 0x0030 } + + /// Get Connection Information + static var getConnectionInformation: ManagementOpcode { 0x0031 } + + /// Get Clock Information + static var getClockInformation: ManagementOpcode { 0x0032 } + + /// Add Device + static var addDevice: ManagementOpcode { 0x0033 } + + /// Remove Device + static var removeDevice: ManagementOpcode { 0x0034 } + + /// Load Connection Parameters + static var loadConnectionParameters: ManagementOpcode { 0x0035 } + + /// Read Unconfigured Controller Index List + static var readUnconfiguredIndexList: ManagementOpcode { 0x0036 } + + /// Read Controller Configuration Information + static var readControllerConfiguration: ManagementOpcode { 0x0037 } + + /// Set External Configuration + static var setExternalConfiguration: ManagementOpcode { 0x0038 } + + /// Set Public Address + static var setPublicAddress: ManagementOpcode { 0x0039 } + + /// Start Service Discovery + static var startServiceDiscovery: ManagementOpcode { 0x003A } + + /// Read Local Out Of Band Extended Data + static var readLocalOutOfBandExtendedData: ManagementOpcode { 0x003B } + + /// Read Extended Controller Index List + static var readExtendedIndexList: ManagementOpcode { 0x003C } + + /// Read Advertising Features + static var readAdvertisingFeatures: ManagementOpcode { 0x003D } + + /// Add Advertising + static var addAdvertising: ManagementOpcode { 0x003E } + + /// Remove Advertising + static var removeAdvertising: ManagementOpcode { 0x003F } + + /// Get Advertising Size Information + static var getAdvertisingSizeInformation: ManagementOpcode { 0x0040 } + + /// Start Limited Discovery + static var startLimitedDiscovery: ManagementOpcode { 0x0041 } + + /// Read Extended Controller Information + static var readExtendedControllerInformation: ManagementOpcode { 0x0042 } + + /// Set Appearance + static var setAppearance: ManagementOpcode { 0x0043 } + + /// Get PHY Configuration + static var getPHYConfiguration: ManagementOpcode { 0x0044 } + + /// Set PHY Configuration + static var setPHYConfiguration: ManagementOpcode { 0x0045 } + + /// Set Blocked Keys + static var setBlockedKeys: ManagementOpcode { 0x0046 } + + /// Set Wideband Speech + static var setWidebandSpeech: ManagementOpcode { 0x0047 } +} + +// MARK: - ExpressibleByIntegerLiteral + +extension ManagementOpcode: ExpressibleByIntegerLiteral { + + public init(integerLiteral value: UInt16) { + self.init(rawValue: value) + } +} + +// MARK: - CustomStringConvertible + +extension ManagementOpcode: CustomStringConvertible, CustomDebugStringConvertible { + + public var description: String { + "0x" + String(rawValue, radix: 16, uppercase: true).padded(to: 4) + } + + public var debugDescription: String { + description + } +} + +internal extension String { + + func padded(to length: Int) -> String { + count < length ? String(repeating: "0", count: length - count) + self : self + } +} diff --git a/Sources/BluetoothLinux/Management/ManagementPacket.swift b/Sources/BluetoothLinux/Management/ManagementPacket.swift new file mode 100644 index 0000000..443842b --- /dev/null +++ b/Sources/BluetoothLinux/Management/ManagementPacket.swift @@ -0,0 +1,147 @@ +// +// ManagementPacket.swift +// BluetoothLinux +// + +import Foundation +import Bluetooth + +/// Bluetooth Management interface command packet header. +/// +/// All fields are little endian on the wire. +@frozen +public struct ManagementCommandHeader: Equatable, Hashable, Sendable { + + /// Header length in bytes. + public static var length: Int { 6 } + + /// Command opcode. + public var opcode: ManagementOpcode + + /// Controller index, or ``HostController/ID/none``. + public var index: HostController.ID + + /// Length of the parameters following the header. + public var parameterLength: UInt16 + + public init( + opcode: ManagementOpcode, + index: HostController.ID = .none, + parameterLength: UInt16 = 0 + ) { + self.opcode = opcode + self.index = index + self.parameterLength = parameterLength + } +} + +public extension ManagementCommandHeader { + + init?(data: Data) { + guard data.count >= Self.length else { + return nil + } + let start = data.startIndex + self.opcode = ManagementOpcode(rawValue: UInt16(littleEndian: UInt16(bytes: (data[start], data[start + 1])))) + self.index = HostController.ID(rawValue: UInt16(littleEndian: UInt16(bytes: (data[start + 2], data[start + 3])))) + self.parameterLength = UInt16(littleEndian: UInt16(bytes: (data[start + 4], data[start + 5]))) + } + + var data: Data { + let opcode = self.opcode.rawValue.littleEndian.bytes + let index = self.index.rawValue.littleEndian.bytes + let length = self.parameterLength.littleEndian.bytes + return Data([opcode.0, opcode.1, index.0, index.1, length.0, length.1]) + } +} + +/// Bluetooth Management interface event packet header. +/// +/// All fields are little endian on the wire. +@frozen +public struct ManagementEventHeader: Equatable, Hashable, Sendable { + + /// Header length in bytes. + public static var length: Int { 6 } + + /// Event code. + public var event: ManagementEvent + + /// Controller index, or ``HostController/ID/none``. + public var index: HostController.ID + + /// Length of the parameters following the header. + public var parameterLength: UInt16 + + public init( + event: ManagementEvent, + index: HostController.ID = .none, + parameterLength: UInt16 = 0 + ) { + self.event = event + self.index = index + self.parameterLength = parameterLength + } +} + +public extension ManagementEventHeader { + + init?(data: Data) { + guard data.count >= Self.length else { + return nil + } + let start = data.startIndex + self.event = ManagementEvent(rawValue: UInt16(littleEndian: UInt16(bytes: (data[start], data[start + 1])))) + self.index = HostController.ID(rawValue: UInt16(littleEndian: UInt16(bytes: (data[start + 2], data[start + 3])))) + self.parameterLength = UInt16(littleEndian: UInt16(bytes: (data[start + 4], data[start + 5]))) + } + + var data: Data { + let event = self.event.rawValue.littleEndian.bytes + let index = self.index.rawValue.littleEndian.bytes + let length = self.parameterLength.littleEndian.bytes + return Data([event.0, event.1, index.0, index.1, length.0, length.1]) + } +} + +/// A Bluetooth Management interface event received from the kernel. +@frozen +public struct ManagementEventNotification: Equatable, Hashable, Sendable { + + /// Event code. + public let event: ManagementEvent + + /// Controller index the event originated from. + public let index: HostController.ID + + /// Event parameters. + public let parameters: Data + + public init( + event: ManagementEvent, + index: HostController.ID, + parameters: Data + ) { + self.event = event + self.index = index + self.parameters = parameters + } +} + +public extension ManagementEventNotification { + + init?(data: Data) { + guard let header = ManagementEventHeader(data: data) else { + return nil + } + let parameters = Data(data.dropFirst(ManagementEventHeader.length)) + guard parameters.count == Int(header.parameterLength) else { + return nil + } + self.init( + event: header.event, + index: header.index, + parameters: parameters + ) + } +} diff --git a/Sources/BluetoothLinux/Management/ManagementSettings.swift b/Sources/BluetoothLinux/Management/ManagementSettings.swift new file mode 100644 index 0000000..493e670 --- /dev/null +++ b/Sources/BluetoothLinux/Management/ManagementSettings.swift @@ -0,0 +1,91 @@ +// +// ManagementSettings.swift +// BluetoothLinux +// + +/// Bluetooth Management interface controller settings. +/// +/// Bitmask describing the supported or current settings of a controller. +@frozen +public struct ManagementSettings: OptionSet, Equatable, Hashable, Sendable { + + public let rawValue: UInt32 + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } +} + +public extension ManagementSettings { + + static var powered: ManagementSettings { ManagementSettings(rawValue: 1 << 0) } + + static var connectable: ManagementSettings { ManagementSettings(rawValue: 1 << 1) } + + static var fastConnectable: ManagementSettings { ManagementSettings(rawValue: 1 << 2) } + + static var discoverable: ManagementSettings { ManagementSettings(rawValue: 1 << 3) } + + static var bondable: ManagementSettings { ManagementSettings(rawValue: 1 << 4) } + + static var linkSecurity: ManagementSettings { ManagementSettings(rawValue: 1 << 5) } + + static var secureSimplePairing: ManagementSettings { ManagementSettings(rawValue: 1 << 6) } + + static var basicRateEnhancedDataRate: ManagementSettings { ManagementSettings(rawValue: 1 << 7) } + + static var highSpeed: ManagementSettings { ManagementSettings(rawValue: 1 << 8) } + + static var lowEnergy: ManagementSettings { ManagementSettings(rawValue: 1 << 9) } + + static var advertising: ManagementSettings { ManagementSettings(rawValue: 1 << 10) } + + static var secureConnections: ManagementSettings { ManagementSettings(rawValue: 1 << 11) } + + static var debugKeys: ManagementSettings { ManagementSettings(rawValue: 1 << 12) } + + static var privacy: ManagementSettings { ManagementSettings(rawValue: 1 << 13) } + + static var configuration: ManagementSettings { ManagementSettings(rawValue: 1 << 14) } + + static var staticAddress: ManagementSettings { ManagementSettings(rawValue: 1 << 15) } + + static var phyConfiguration: ManagementSettings { ManagementSettings(rawValue: 1 << 16) } + + static var widebandSpeech: ManagementSettings { ManagementSettings(rawValue: 1 << 17) } +} + +// MARK: - CustomStringConvertible + +extension ManagementSettings: CustomStringConvertible, CustomDebugStringConvertible { + + public var description: String { + let names: [(ManagementSettings, String)] = [ + (.powered, "powered"), + (.connectable, "connectable"), + (.fastConnectable, "fast-connectable"), + (.discoverable, "discoverable"), + (.bondable, "bondable"), + (.linkSecurity, "link-security"), + (.secureSimplePairing, "ssp"), + (.basicRateEnhancedDataRate, "br/edr"), + (.highSpeed, "hs"), + (.lowEnergy, "le"), + (.advertising, "advertising"), + (.secureConnections, "secure-conn"), + (.debugKeys, "debug-keys"), + (.privacy, "privacy"), + (.configuration, "configuration"), + (.staticAddress, "static-addr"), + (.phyConfiguration, "phy-configuration"), + (.widebandSpeech, "wideband-speech") + ] + return names + .compactMap { contains($0.0) ? $0.1 : nil } + .joined(separator: " ") + } + + public var debugDescription: String { + description + } +} diff --git a/Sources/BluetoothLinux/Management/ManagementSocket.swift b/Sources/BluetoothLinux/Management/ManagementSocket.swift new file mode 100644 index 0000000..4e9b158 --- /dev/null +++ b/Sources/BluetoothLinux/Management/ManagementSocket.swift @@ -0,0 +1,136 @@ +// +// ManagementSocket.swift +// BluetoothLinux +// + +import Foundation +import Bluetooth +import BluetoothHCI +import SystemPackage +import Socket + +/// Bluetooth Management interface socket. +/// +/// Opens an HCI control channel (`HCI_CHANNEL_CONTROL`) socket for controller management +/// (power, discoverable, connectable, pairing, and other adapter settings). +/// +/// Unlike raw HCI sockets, the control channel is not bound to a single controller; +/// commands target a controller by index and events report the originating index. +/// Most commands require the `CAP_NET_ADMIN` capability (root). +public actor ManagementSocket { + + // MARK: - Properties + + /// Maximum size of a management interface message in bytes. + public static var maximumMessageLength: Int { ManagementEventHeader.length + Int(UInt16.max) } + + @usableFromInline + internal let socket: Socket + + // MARK: - Initialization + + deinit { + let socket = self.socket + Task(priority: .high) { + await socket.close() + } + } + + /// Open a management interface socket. + public init() async throws { + let address = HCISocketAddress( + device: .none, + channel: .control + ) + let fileDescriptor = try SocketDescriptor.hci(address, flags: [.closeOnExec, .nonBlocking]) + self.socket = await Socket(fileDescriptor: fileDescriptor) + } + + // MARK: - Methods + + /// Send a command and wait for its response. + /// + /// - Parameter opcode: The command to send. + /// - Parameter index: The controller the command targets, or ``HostController/ID/none``. + /// - Parameter parameters: The command parameters. + /// + /// - Returns: The return parameters of the command. + /// + /// - Throws: ``ManagementStatus`` if the kernel rejects the command. + @discardableResult + public func send( + _ opcode: ManagementOpcode, + index: HostController.ID = .none, + parameters: Data = Data() + ) async throws -> Data { + assert(parameters.count <= UInt16.max) + let header = ManagementCommandHeader( + opcode: opcode, + index: index, + parameterLength: UInt16(parameters.count) + ) + var packet = header.data + packet.append(parameters) + _ = try await socket.write(packet) + // wait for a response to this command, ignoring unrelated events + while true { + let notification = try await receive() + guard let response = ManagementCommandResponse(notification) else { + continue + } + guard response.opcode == opcode else { + continue + } + guard response.status == .success else { + throw response.status + } + switch notification.event { + case .commandComplete: + return response.parameters + case .commandStatus: + // command accepted, wait for completion + continue + default: + continue + } + } + } + + /// Receive the next event. + public func receive() async throws -> ManagementEventNotification { + let data = try await socket.read(Self.maximumMessageLength) + guard let notification = ManagementEventNotification(data: data) else { + throw BluetoothHostControllerError.garbageResponse(data) + } + return notification + } +} + +// MARK: - Supporting Types + +/// Parsed parameters of a Command Complete or Command Status event. +internal struct ManagementCommandResponse { + + let opcode: ManagementOpcode + + let status: ManagementStatus + + let parameters: Data + + init?(_ notification: ManagementEventNotification) { + guard notification.event == .commandComplete || notification.event == .commandStatus else { + return nil + } + let data = notification.parameters + guard data.count >= 3 else { + return nil + } + let start = data.startIndex + self.opcode = ManagementOpcode(rawValue: UInt16(littleEndian: UInt16(bytes: (data[start], data[start + 1])))) + guard let status = ManagementStatus(rawValue: data[start + 2]) else { + return nil + } + self.status = status + self.parameters = Data(data.dropFirst(3)) + } +} diff --git a/Sources/BluetoothLinux/Management/ManagementStatus.swift b/Sources/BluetoothLinux/Management/ManagementStatus.swift new file mode 100644 index 0000000..1ea821f --- /dev/null +++ b/Sources/BluetoothLinux/Management/ManagementStatus.swift @@ -0,0 +1,65 @@ +// +// ManagementStatus.swift +// BluetoothLinux +// + +/// Bluetooth Management interface command status. +public enum ManagementStatus: UInt8, Error, Equatable, Hashable, Sendable, CaseIterable { + + case success = 0x00 + case unknownCommand = 0x01 + case notConnected = 0x02 + case failed = 0x03 + case connectFailed = 0x04 + case authenticationFailed = 0x05 + case notPaired = 0x06 + case noResources = 0x07 + case timeout = 0x08 + case alreadyConnected = 0x09 + case busy = 0x0A + case rejected = 0x0B + case notSupported = 0x0C + case invalidParameters = 0x0D + case disconnected = 0x0E + case notPowered = 0x0F + case cancelled = 0x10 + case invalidIndex = 0x11 + case rfKilled = 0x12 + case alreadyPaired = 0x13 + case permissionDenied = 0x14 +} + +// MARK: - CustomStringConvertible + +extension ManagementStatus: CustomStringConvertible, CustomDebugStringConvertible { + + public var description: String { + switch self { + case .success: return "Success" + case .unknownCommand: return "Unknown Command" + case .notConnected: return "Not Connected" + case .failed: return "Failed" + case .connectFailed: return "Connect Failed" + case .authenticationFailed: return "Authentication Failed" + case .notPaired: return "Not Paired" + case .noResources: return "No Resources" + case .timeout: return "Timeout" + case .alreadyConnected: return "Already Connected" + case .busy: return "Busy" + case .rejected: return "Rejected" + case .notSupported: return "Not Supported" + case .invalidParameters: return "Invalid Parameters" + case .disconnected: return "Disconnected" + case .notPowered: return "Not Powered" + case .cancelled: return "Cancelled" + case .invalidIndex: return "Invalid Index" + case .rfKilled: return "RF Killed" + case .alreadyPaired: return "Already Paired" + case .permissionDenied: return "Permission Denied" + } + } + + public var debugDescription: String { + description + } +} diff --git a/Tests/BluetoothLinuxTests/ManagementTests.swift b/Tests/BluetoothLinuxTests/ManagementTests.swift new file mode 100644 index 0000000..621f77a --- /dev/null +++ b/Tests/BluetoothLinuxTests/ManagementTests.swift @@ -0,0 +1,143 @@ +// +// ManagementTests.swift +// BluetoothLinux +// + +import Foundation +import XCTest +import Bluetooth +@testable import BluetoothLinux + +final class ManagementTests: XCTestCase { + + func testCommandHeader() { + let header = ManagementCommandHeader( + opcode: .setPowered, + index: .init(rawValue: 0), + parameterLength: 1 + ) + let data = header.data + XCTAssertEqual(data, Data([0x05, 0x00, 0x00, 0x00, 0x01, 0x00])) + XCTAssertEqual(ManagementCommandHeader(data: data), header) + } + + func testEventHeader() { + let header = ManagementEventHeader( + event: .commandComplete, + index: .init(rawValue: 1), + parameterLength: 3 + ) + let data = header.data + XCTAssertEqual(data, Data([0x01, 0x00, 0x01, 0x00, 0x03, 0x00])) + XCTAssertEqual(ManagementEventHeader(data: data), header) + } + + func testEventNotification() { + // Command Complete for Read Management Version Information (0x0001) + // status success, version 1, revision 22 + let data = Data([ + 0x01, 0x00, // Command Complete + 0xFF, 0xFF, // no controller index + 0x06, 0x00, // parameter length + 0x01, 0x00, // opcode + 0x00, // status + 0x01, // version + 0x16, 0x00 // revision + ]) + guard let notification = ManagementEventNotification(data: data) else { + XCTFail("Could not parse event") + return + } + XCTAssertEqual(notification.event, .commandComplete) + XCTAssertEqual(notification.index, .none) + XCTAssertEqual(notification.parameters.count, 6) + guard let response = ManagementCommandResponse(notification) else { + XCTFail("Could not parse response") + return + } + XCTAssertEqual(response.opcode, .readVersion) + XCTAssertEqual(response.status, .success) + XCTAssertEqual(response.parameters, Data([0x01, 0x16, 0x00])) + } + + func testEventNotificationInvalidLength() { + // header declares more parameters than present + let data = Data([0x01, 0x00, 0xFF, 0xFF, 0x0A, 0x00, 0x01]) + XCTAssertNil(ManagementEventNotification(data: data)) + } + + func testCommandStatusResponse() { + // Command Status for Set Powered (0x0005), permission denied + let notification = ManagementEventNotification( + event: .commandStatus, + index: .init(rawValue: 0), + parameters: Data([0x05, 0x00, 0x14]) + ) + guard let response = ManagementCommandResponse(notification) else { + XCTFail("Could not parse response") + return + } + XCTAssertEqual(response.opcode, .setPowered) + XCTAssertEqual(response.status, .permissionDenied) + } + + func testControllerInformation() { + var data = Data() + data.append(contentsOf: [0x13, 0x71, 0xDA, 0x7D, 0x1A, 0x00]) // 00:1A:7D:DA:71:13 + data.append(0x08) // version 4.2 + data.append(contentsOf: [0x0F, 0x00]) // manufacturer (Broadcom) + data.append(contentsOf: [0xFF, 0x02, 0x00, 0x00]) // supported settings + data.append(contentsOf: [0x81, 0x02, 0x00, 0x00]) // current settings (powered, br/edr, le) + data.append(contentsOf: [0x0C, 0x01, 0x1C]) // class of device + var name = Data("Test Controller".utf8) + name.append(contentsOf: repeatElement(0, count: ManagementControllerInformation.maximumNameLength - name.count)) + data.append(name) + var shortName = Data("Test".utf8) + shortName.append(contentsOf: repeatElement(0, count: ManagementControllerInformation.maximumShortNameLength - shortName.count)) + data.append(shortName) + XCTAssertEqual(data.count, ManagementControllerInformation.length) + guard let information = ManagementControllerInformation(data: data) else { + XCTFail("Could not parse controller information") + return + } + XCTAssertEqual(information.address, BluetoothAddress(rawValue: "00:1A:7D:DA:71:13")) + XCTAssertEqual(information.version, 0x08) + XCTAssertEqual(information.manufacturer, 15) + XCTAssertEqual(information.supportedSettings.rawValue, 0x02FF) + XCTAssertEqual(information.currentSettings, [.powered, .basicRateEnhancedDataRate, .lowEnergy]) + XCTAssertEqual(information.classOfDevice.0, 0x0C) + XCTAssertEqual(information.classOfDevice.1, 0x01) + XCTAssertEqual(information.classOfDevice.2, 0x1C) + XCTAssertEqual(information.name, "Test Controller") + XCTAssertEqual(information.shortName, "Test") + } + + func testSettings() { + XCTAssertThrowsError(try ManagementSocket.settings(from: Data([0x01]))) + XCTAssertEqual( + try ManagementSocket.settings(from: Data([0x81, 0x02, 0x00, 0x00])), + [.powered, .basicRateEnhancedDataRate, .lowEnergy] + ) + } + + func testFixedLengthString() { + let data = ManagementSocket.fixedLengthString("Test", length: 11) + XCTAssertEqual(data.count, 11) + XCTAssertEqual(data, Data([0x54, 0x65, 0x73, 0x74, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])) + // truncation preserves the null terminator + let truncated = ManagementSocket.fixedLengthString("A very long controller name", length: 11) + XCTAssertEqual(truncated.count, 11) + XCTAssertEqual(truncated.last, 0x00) + } + + func testStatusDescription() { + for status in ManagementStatus.allCases { + XCTAssertFalse(status.description.isEmpty) + } + } + + func testSettingsDescription() { + let settings: ManagementSettings = [.powered, .lowEnergy] + XCTAssertEqual(settings.description, "powered le") + } +}