diff --git a/Sources/BluetoothLinux/SCO/SCOFileDescriptor.swift b/Sources/BluetoothLinux/SCO/SCOFileDescriptor.swift new file mode 100644 index 0000000..b9fa1b6 --- /dev/null +++ b/Sources/BluetoothLinux/SCO/SCOFileDescriptor.swift @@ -0,0 +1,23 @@ +// +// SCOFileDescriptor.swift +// BluetoothLinux +// + +import Bluetooth +import Socket + +internal extension SocketDescriptor { + + /// Creates an SCO socket binded to the specified address. + @usableFromInline + static func sco( + _ address: SCOSocketAddress, + _ flags: SocketFlags + ) throws(Errno) -> SocketDescriptor { + try bluetooth( + .sco, + bind: address, + flags: flags + ) + } +} diff --git a/Sources/BluetoothLinux/SCO/SCOSocket.swift b/Sources/BluetoothLinux/SCO/SCOSocket.swift index 5e6a412..a087f87 100644 --- a/Sources/BluetoothLinux/SCO/SCOSocket.swift +++ b/Sources/BluetoothLinux/SCO/SCOSocket.swift @@ -1,15 +1,181 @@ // // SCOSocket.swift -// -// -// Created by Alsey Coleman Miller on 16/10/21. +// BluetoothLinux // import Foundation +import Bluetooth import SystemPackage +import Socket /// Bluetooth SCO Socket -public actor SCOSocket { - - +/// +/// Synchronous Connection Oriented links carry voice audio +/// between the host and a connected classic (BR/EDR) device. +public struct SCOSocket: Sendable { + + // MARK: - Properties + + @usableFromInline + internal let fileDescriptor: SocketDescriptor + + /// Socket address. + public let address: SCOSocketAddress + + // MARK: - Initialization + + internal init( + fileDescriptor: SocketDescriptor, + address: SCOSocketAddress + ) { + self.fileDescriptor = fileDescriptor + self.address = address + } + + /// Create a new SCO socket bound to the specified address. + public init(address: SCOSocketAddress) throws(Errno) { + self.fileDescriptor = try .sco(address, [.closeOnExec, .nonBlocking]) + self.address = address + } + + /// Creates a client socket connected to the specified remote device. + /// + /// - Note: An ACL connection to the remote device must already exist, + /// and a non-default air coding format (``BluetoothSocketOption/Voice``) + /// must be configured before connecting. + public static func client( + address localAddress: BluetoothAddress, + destination destinationAddress: BluetoothAddress, + voice: BluetoothSocketOption.Voice? = nil + ) throws(Errno) -> Self { + let localSocketAddress = SCOSocketAddress(address: localAddress) + let destinationSocketAddress = SCOSocketAddress(address: destinationAddress) + let fileDescriptor = try SocketDescriptor.sco(localSocketAddress, [.closeOnExec, .nonBlocking]) + + // configure the air coding format before connecting + if let voice { + do { + try fileDescriptor.setSocketOption(voice) + } catch { + try? fileDescriptor.close() + throw error + } + } + + // Start async connect - for non-blocking sockets this returns EINPROGRESS + do { + try fileDescriptor.connect(to: destinationSocketAddress) + } catch Errno.nowInProgress { + // Expected for non-blocking socket - connection is in progress + // Wait for socket to become writable (indicates connect completed) + let timeout: Int = 30_000 // 30 seconds in milliseconds + let events = try fileDescriptor.poll(for: [.write, .error, .hangup], timeout: timeout) + + // Check for errors + if events.contains(.error) || events.contains(.hangup) { + try? fileDescriptor.close() + throw Errno.connectionRefused + } + + // Check if we timed out (no events returned) + if !events.contains(.write) { + try? fileDescriptor.close() + throw Errno.timedOut + } + } catch { + // Other errors during connect + try? fileDescriptor.close() + throw error + } + + return Self.init(fileDescriptor: fileDescriptor, address: localSocketAddress) + } + + /// Creates a server socket listening on the specified address. + public static func server( + address: BluetoothAddress, + backlog: Int = Socket.maxBacklog + ) throws(Errno) -> Self { + let socketAddress = SCOSocketAddress(address: address) + let fileDescriptor = try SocketDescriptor.sco(socketAddress, [.closeOnExec, .nonBlocking]) + try fileDescriptor.closeIfThrows { () throws(Errno) -> () in + try fileDescriptor.listen(backlog: backlog) + } + return Self.init(fileDescriptor: fileDescriptor, address: socketAddress) + } + + // MARK: - Methods + + /// Close socket. + public func close() { + try? fileDescriptor.close() + } + + /// Attempt to accept an incoming connection. + public func accept() throws(Errno) -> Self { + let (fileDescriptor, address) = try self.fileDescriptor.accept(SCOSocketAddress.self) + return Self.init( + fileDescriptor: fileDescriptor, + address: address + ) + } + + /// Write to the socket. + public func send(_ data: Data) throws(Errno) -> Int { + do { + return try data.withUnsafeBytes { (bytes) throws(Errno) -> Int in + try fileDescriptor.write(bytes) + } + } + catch { + throw error as! Errno // TODO: Foundation doesnt support typed error yet + } + } + + /// Reads from the socket. + public func receive(_ length: Int) throws(Errno) -> Data { + do { + var data = Data(count: length) + let bytesRead = try data.withUnsafeMutableBytes { (bytes) throws(Errno) -> Int in + try fileDescriptor.read(into: bytes) + } + if bytesRead < length { + data = data.prefix(bytesRead) + } + return data + } + catch { + throw error as! Errno // TODO: Foundation doesnt support typed error yet + } + } + + // MARK: - Options + + /// Socket options (maximum transmission unit). + public var options: SCOSocketOption.Options { + get throws(Errno) { + try fileDescriptor.getSocketOption(SCOSocketOption.Options.self) + } + } + + /// Connection information (handle and device class). + public var connectionInfo: SCOSocketOption.ConnectionInfo { + get throws(Errno) { + try fileDescriptor.getSocketOption(SCOSocketOption.ConnectionInfo.self) + } + } + + /// The voice setting (air coding format) of the socket. + public var voice: BluetoothSocketOption.Voice { + get throws(Errno) { + try fileDescriptor.getSocketOption(BluetoothSocketOption.Voice.self) + } + } + + /// Set the voice setting (air coding format) of the socket. + /// + /// Must be configured before the connection is established. + public func setVoice(_ voice: BluetoothSocketOption.Voice) throws(Errno) { + try fileDescriptor.setSocketOption(voice) + } } diff --git a/Sources/BluetoothLinux/SCO/SCOSocketAddress.swift b/Sources/BluetoothLinux/SCO/SCOSocketAddress.swift new file mode 100644 index 0000000..b49988c --- /dev/null +++ b/Sources/BluetoothLinux/SCO/SCOSocketAddress.swift @@ -0,0 +1,55 @@ +// +// SCOSocketAddress.swift +// BluetoothLinux +// + +import SystemPackage +import Socket +import Bluetooth + +/// Bluetooth SCO Socket Address +@frozen +public struct SCOSocketAddress: Equatable, Hashable, Sendable { + + // MARK: - Properties + + /// Bluetooth address + public var address: BluetoothAddress + + // MARK: - Initialization + + public init(address: BluetoothAddress) { + self.address = address + } +} + +extension SCOSocketAddress: BluetoothSocketAddress { + + @_alwaysEmitIntoClient + public static var protocolID: BluetoothSocketProtocol { .sco } + + /// Unsafe pointer closure + public func withUnsafePointer( + _ body: (UnsafePointer, UInt32) throws(Error) -> Result + ) rethrows -> Result where Error: Swift.Error { + var value = CInterop.SCOSocketAddress() + value.address = address.littleEndian + return try value.withUnsafePointer(body) + } + + public static func withUnsafePointer( + _ body: (UnsafeMutablePointer, UInt32) throws(Error) -> () + ) rethrows -> Self where Error: Swift.Error { + var value = CInterop.SCOSocketAddress() + try value.withUnsafeMutablePointer(body) + return Self.init(address: .init(littleEndian: value.address)) + } + + public static func withUnsafePointer( + _ pointer: UnsafeMutablePointer + ) -> Self { + pointer.withMemoryRebound(to: CInterop.SCOSocketAddress.self, capacity: 1) { pointer in + Self.init(address: .init(littleEndian: pointer.pointee.address)) + } + } +} diff --git a/Sources/BluetoothLinux/SCO/SCOSocketOption.swift b/Sources/BluetoothLinux/SCO/SCOSocketOption.swift index 83b021e..a9b8801 100644 --- a/Sources/BluetoothLinux/SCO/SCOSocketOption.swift +++ b/Sources/BluetoothLinux/SCO/SCOSocketOption.swift @@ -1,8 +1,101 @@ // -// File.swift -// -// -// Created by Alsey Coleman Miller on 16/10/21. +// SCOSocketOption.swift +// BluetoothLinux // -import Foundation +import SystemPackage +import Socket + +/// SCO Socket Options +public enum SCOSocketOption: CInt, SocketOptionID { + + public static var optionLevel: SocketOptionLevel { .sco } + + /// SCO Socket Options + case options = 0x01 + + /// SCO Connection Info + case connectionInfo = 0x02 +} + +public extension SCOSocketOption { + + /// SCO Socket Options + /// + /// `sco_options` + struct Options: Equatable, Hashable, SocketOption, Sendable { + + @_alwaysEmitIntoClient + public static var id: SCOSocketOption { .options } + + /// Maximum transmission unit. + public var maximumTransmissionUnit: UInt16 // mtu + + public init() { + self.maximumTransmissionUnit = 0 + } + + public func withUnsafeBytes(_ body: ((UnsafeRawBufferPointer) throws(Error) -> (Result))) rethrows -> Result where Error: Swift.Error { + return try Swift.withUnsafeBytes(of: self) { bufferPointer in + try body(bufferPointer) + } + } + + public static func withUnsafeBytes( + _ body: (UnsafeMutableRawBufferPointer) throws(Error) -> () + ) rethrows -> Self where Error: Swift.Error { + var value = self.init() + try Swift.withUnsafeMutableBytes(of: &value, body) + return value + } + } + + /// SCO Connection Information + /// + /// `sco_conninfo` + struct ConnectionInfo: SocketOption, Sendable { + + @_alwaysEmitIntoClient + public static var id: SCOSocketOption { .connectionInfo } + + /// Connection handle. + public var handle: UInt16 + + /// Device class. + public var deviceClass: (UInt8, UInt8, UInt8) + + public init() { + self.handle = 0 + self.deviceClass = (0, 0, 0) + } + + public func withUnsafeBytes(_ body: ((UnsafeRawBufferPointer) throws(Error) -> (Result))) rethrows -> Result where Error: Swift.Error { + return try Swift.withUnsafeBytes(of: self) { bufferPointer in + try body(bufferPointer) + } + } + + public static func withUnsafeBytes( + _ body: (UnsafeMutableRawBufferPointer) throws(Error) -> () + ) rethrows -> Self where Error: Swift.Error { + var value = self.init() + try Swift.withUnsafeMutableBytes(of: &value, body) + return value + } + } +} + +extension SCOSocketOption.ConnectionInfo: Equatable, Hashable { + + public static func == (lhs: SCOSocketOption.ConnectionInfo, rhs: SCOSocketOption.ConnectionInfo) -> Bool { + lhs.handle == rhs.handle + && lhs.deviceClass == rhs.deviceClass + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(handle) + hasher.combine(deviceClass.0) + hasher.combine(deviceClass.1) + hasher.combine(deviceClass.2) + } +} diff --git a/Sources/BluetoothLinux/SocketOptions/VoiceSocketOption.swift b/Sources/BluetoothLinux/SocketOptions/VoiceSocketOption.swift new file mode 100644 index 0000000..95dc32d --- /dev/null +++ b/Sources/BluetoothLinux/SocketOptions/VoiceSocketOption.swift @@ -0,0 +1,63 @@ +// +// VoiceSocketOption.swift +// BluetoothLinux +// + +import Bluetooth +import SystemPackage +import Socket + +public extension BluetoothSocketOption { + + /// Bluetooth Voice socket option + /// + /// Configures the air coding format of an SCO socket (`bt_voice`). + /// Must be set before the connection is established. + struct Voice: Equatable, Hashable, SocketOption, Sendable { + + @_alwaysEmitIntoClient + public static var id: BluetoothSocketOption { .voice } + + /// Voice setting. + public var setting: Setting + + public init(setting: Setting = .cvsd) { + self.setting = setting + } + + public func withUnsafeBytes(_ body: ((UnsafeRawBufferPointer) throws(Error) -> (Result))) rethrows -> Result where Error: Swift.Error { + return try Swift.withUnsafeBytes(of: setting.rawValue) { bufferPointer in + try body(bufferPointer) + } + } + + public static func withUnsafeBytes( + _ body: (UnsafeMutableRawBufferPointer) throws(Error) -> () + ) rethrows -> Self where Error: Swift.Error { + var rawValue: UInt16 = 0 + try Swift.withUnsafeMutableBytes(of: &rawValue, body) + return Self.init(setting: Setting(rawValue: rawValue)) + } + } +} + +public extension BluetoothSocketOption.Voice { + + /// Voice setting. + @frozen + struct Setting: RawRepresentable, Equatable, Hashable, Sendable { + + public let rawValue: UInt16 + + public init(rawValue: UInt16) { + self.rawValue = rawValue + } + + /// CVSD air coding format (`BT_VOICE_CVSD_16BIT`). + public static var cvsd: Setting { Setting(rawValue: 0x0060) } + + /// Transparent air coding format (`BT_VOICE_TRANSPARENT`), + /// used for wideband speech codecs such as mSBC. + public static var transparent: Setting { Setting(rawValue: 0x0003) } + } +} diff --git a/Tests/BluetoothLinuxTests/SCOTests.swift b/Tests/BluetoothLinuxTests/SCOTests.swift new file mode 100644 index 0000000..351e27d --- /dev/null +++ b/Tests/BluetoothLinuxTests/SCOTests.swift @@ -0,0 +1,68 @@ +// +// SCOTests.swift +// BluetoothLinuxTests +// + +import Foundation +import XCTest +import Bluetooth +import SystemPackage +import Socket +@testable import BluetoothLinux + +final class SCOTests: XCTestCase { + + func testSocketOptionValues() { + XCTAssertEqual(SCOSocketOption.optionLevel.rawValue, 17) // SOL_SCO + XCTAssertEqual(SCOSocketOption.options.rawValue, 0x01) + XCTAssertEqual(SCOSocketOption.connectionInfo.rawValue, 0x02) + XCTAssertEqual(BluetoothSocketOption.voice.rawValue, 11) // BT_VOICE + } + + func testVoiceSetting() { + XCTAssertEqual(BluetoothSocketOption.Voice.Setting.cvsd.rawValue, 0x0060) + XCTAssertEqual(BluetoothSocketOption.Voice.Setting.transparent.rawValue, 0x0003) + XCTAssertEqual(BluetoothSocketOption.Voice().setting, .cvsd) + } + + func testVoiceEncoding() { + // `bt_voice` is a single 16-bit setting + let voice = BluetoothSocketOption.Voice(setting: .transparent) + voice.withUnsafeBytes { buffer in + XCTAssertEqual(buffer.count, 2) + XCTAssertEqual(buffer[0], 0x03) + XCTAssertEqual(buffer[1], 0x00) + } + let decoded = BluetoothSocketOption.Voice.withUnsafeBytes { buffer in + buffer[0] = 0x60 + buffer[1] = 0x00 + } + XCTAssertEqual(decoded.setting, .cvsd) + } + + func testOptionsLayout() { + // `sco_options` is a single 16-bit MTU + var options = SCOSocketOption.Options() + options.maximumTransmissionUnit = 64 + options.withUnsafeBytes { buffer in + XCTAssertEqual(buffer.count, 2) + } + // `sco_conninfo` is a 16-bit handle and 3 byte device class + let info = SCOSocketOption.ConnectionInfo() + info.withUnsafeBytes { buffer in + XCTAssertEqual(buffer.count, MemoryLayout.size) + } + } + + #if os(Linux) + func testSocketAddressEncoding() { + // socket address construction requires the Bluetooth address family (Linux only) + let address = SCOSocketAddress(address: BluetoothAddress(rawValue: "00:1A:7D:DA:71:13")!) + address.withUnsafePointer { pointer, size in + XCTAssertEqual(Int(size), MemoryLayout.size) + let bytes = UnsafeRawPointer(pointer).loadUnaligned(as: CInterop.SCOSocketAddress.self) + XCTAssertEqual(BluetoothAddress(littleEndian: bytes.address).rawValue, "00:1A:7D:DA:71:13") + } + } + #endif +}