diff --git a/Sources/BluetoothLinux/ISO/ISOFileDescriptor.swift b/Sources/BluetoothLinux/ISO/ISOFileDescriptor.swift new file mode 100644 index 0000000..05847dd --- /dev/null +++ b/Sources/BluetoothLinux/ISO/ISOFileDescriptor.swift @@ -0,0 +1,23 @@ +// +// ISOFileDescriptor.swift +// BluetoothLinux +// + +import Bluetooth +import Socket + +internal extension SocketDescriptor { + + /// Creates an ISO socket binded to the specified address. + @usableFromInline + static func iso( + _ address: ISOSocketAddress, + _ flags: SocketFlags + ) throws(Errno) -> SocketDescriptor { + try bluetooth( + .iso, + bind: address, + flags: flags + ) + } +} diff --git a/Sources/BluetoothLinux/ISO/ISOQualityOfService.swift b/Sources/BluetoothLinux/ISO/ISOQualityOfService.swift new file mode 100644 index 0000000..e3e4be0 --- /dev/null +++ b/Sources/BluetoothLinux/ISO/ISOQualityOfService.swift @@ -0,0 +1,112 @@ +// +// ISOQualityOfService.swift +// BluetoothLinux +// + +import Bluetooth +import SystemPackage +import Socket + +public extension BluetoothSocketOption { + + /// Bluetooth Isochronous Channel Quality of Service socket option + /// + /// Configures the connected isochronous stream parameters of an ISO socket + /// (`bt_iso_qos`, unicast). Must be set before the connection is established. + struct ISOQualityOfService: Equatable, Hashable, SocketOption, Sendable { + + @_alwaysEmitIntoClient + public static var id: BluetoothSocketOption { .isoQualityOfService } + + /// The connected isochronous group identifier (`0xFF` for unset). + public var group: UInt8 // cig + + /// The connected isochronous stream identifier (`0xFF` for unset). + public var stream: UInt8 // cis + + /// The sleep clock accuracy. + public var sleepClockAccuracy: UInt8 // sca + + /// The preferred method of arranging subevents of multiple streams. + public var packing: UInt8 + + /// The format of the sent data (unframed or framed). + public var framing: UInt8 + + /// Input (receive) parameters. + public var input: IO + + /// Output (send) parameters. + public var output: IO + + public init( + group: UInt8 = 0xFF, + stream: UInt8 = 0xFF, + sleepClockAccuracy: UInt8 = 0, + packing: UInt8 = 0, + framing: UInt8 = 0, + input: IO = IO(), + output: IO = IO() + ) { + self.group = group + self.stream = stream + self.sleepClockAccuracy = sleepClockAccuracy + self.packing = packing + self.framing = framing + self.input = input + self.output = output + } + + 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 + } + } +} + +public extension BluetoothSocketOption.ISOQualityOfService { + + /// Isochronous stream input/output parameters. + /// + /// `bt_iso_io_qos` + struct IO: Equatable, Hashable, Sendable { + + /// SDU interval in microseconds. + public var interval: UInt32 + + /// Maximum transport latency in milliseconds. + public var latency: UInt16 + + /// Maximum SDU size in octets. + public var sdu: UInt16 + + /// PHY to use. + public var phy: UInt8 + + /// Retransmission number. + public var retransmissionNumber: UInt8 // rtn + + public init( + interval: UInt32 = 0, + latency: UInt16 = 0, + sdu: UInt16 = 0, + phy: UInt8 = 0, + retransmissionNumber: UInt8 = 0 + ) { + self.interval = interval + self.latency = latency + self.sdu = sdu + self.phy = phy + self.retransmissionNumber = retransmissionNumber + } + } +} diff --git a/Sources/BluetoothLinux/ISO/ISOSocket.swift b/Sources/BluetoothLinux/ISO/ISOSocket.swift new file mode 100644 index 0000000..7b59cb2 --- /dev/null +++ b/Sources/BluetoothLinux/ISO/ISOSocket.swift @@ -0,0 +1,165 @@ +// +// ISOSocket.swift +// BluetoothLinux +// + +import Foundation +import Bluetooth +import SystemPackage +import Socket + +/// Bluetooth ISO Socket +/// +/// Isochronous channels carry time-sensitive audio data between the host +/// and Low Energy devices (LE Audio). Requires kernel 6.0 or later; on older +/// kernels the ISO socket is gated behind an experimental feature toggled +/// via the management interface. +public struct ISOSocket: Sendable { + + // MARK: - Properties + + @usableFromInline + internal let fileDescriptor: SocketDescriptor + + /// Socket address. + public let address: ISOSocketAddress + + // MARK: - Initialization + + internal init( + fileDescriptor: SocketDescriptor, + address: ISOSocketAddress + ) { + self.fileDescriptor = fileDescriptor + self.address = address + } + + /// Create a new ISO socket bound to the specified address. + public init(address: ISOSocketAddress) throws(Errno) { + self.fileDescriptor = try .iso(address, [.closeOnExec, .nonBlocking]) + self.address = address + } + + /// Creates a client socket connected to the specified remote device. + /// + /// - Note: The quality of service must be configured before connecting + /// when using non-default stream parameters. + public static func client( + address localAddress: ISOSocketAddress, + destination destinationAddress: ISOSocketAddress, + qualityOfService: BluetoothSocketOption.ISOQualityOfService? = nil + ) throws(Errno) -> Self { + let fileDescriptor = try SocketDescriptor.iso(localAddress, [.closeOnExec, .nonBlocking]) + + // configure stream parameters before connecting + if let qualityOfService { + do { + try fileDescriptor.setSocketOption(qualityOfService) + } catch { + try? fileDescriptor.close() + throw error + } + } + + // Start async connect - for non-blocking sockets this returns EINPROGRESS + do { + try fileDescriptor.connect(to: destinationAddress) + } 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: localAddress) + } + + /// Creates a server socket listening on the specified address. + public static func server( + address: ISOSocketAddress, + backlog: Int = Socket.maxBacklog + ) throws(Errno) -> Self { + let fileDescriptor = try SocketDescriptor.iso(address, [.closeOnExec, .nonBlocking]) + try fileDescriptor.closeIfThrows { () throws(Errno) -> () in + try fileDescriptor.listen(backlog: backlog) + } + return Self.init(fileDescriptor: fileDescriptor, address: address) + } + + // 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(ISOSocketAddress.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 + + /// The quality of service of the socket. + public var qualityOfService: BluetoothSocketOption.ISOQualityOfService { + get throws(Errno) { + try fileDescriptor.getSocketOption(BluetoothSocketOption.ISOQualityOfService.self) + } + } + + /// Set the quality of service of the socket. + /// + /// Must be configured before the connection is established. + public func setQualityOfService(_ qualityOfService: BluetoothSocketOption.ISOQualityOfService) throws(Errno) { + try fileDescriptor.setSocketOption(qualityOfService) + } +} diff --git a/Sources/BluetoothLinux/ISO/ISOSocketAddress.swift b/Sources/BluetoothLinux/ISO/ISOSocketAddress.swift new file mode 100644 index 0000000..ec23fb6 --- /dev/null +++ b/Sources/BluetoothLinux/ISO/ISOSocketAddress.swift @@ -0,0 +1,74 @@ +// +// ISOSocketAddress.swift +// BluetoothLinux +// + +import SystemPackage +import Socket +import Bluetooth + +/// Bluetooth ISO Socket Address +@frozen +public struct ISOSocketAddress: Equatable, Hashable, Sendable { + + // MARK: - Properties + + /// Bluetooth address + public var address: BluetoothAddress + + /// Bluetooth address type + public var addressType: AddressType + + // MARK: - Initialization + + public init( + address: BluetoothAddress, + addressType: AddressType = .lowEnergyPublic + ) { + self.address = address + self.addressType = addressType + } +} + +extension ISOSocketAddress: BluetoothSocketAddress { + + @_alwaysEmitIntoClient + public static var protocolID: BluetoothSocketProtocol { .iso } + + /// Unsafe pointer closure + public func withUnsafePointer( + _ body: (UnsafePointer, UInt32) throws(Error) -> Result + ) rethrows -> Result where Error: Swift.Error { + var value = CInterop.ISOSocketAddress() + value.address = address.littleEndian + value.type = addressType.rawValue + return try value.withUnsafePointer(body) + } + + public static func withUnsafePointer( + _ body: (UnsafeMutablePointer, UInt32) throws(Error) -> () + ) rethrows -> Self where Error: Swift.Error { + var value = CInterop.ISOSocketAddress() + try value.withUnsafeMutablePointer(body) + return Self.init(value) + } + + public static func withUnsafePointer( + _ pointer: UnsafeMutablePointer + ) -> Self { + pointer.withMemoryRebound(to: CInterop.ISOSocketAddress.self, capacity: 1) { pointer in + Self.init(pointer.pointee) + } + } +} + +internal extension ISOSocketAddress { + + @usableFromInline + init(_ bytes: CInterop.ISOSocketAddress) { + self.init( + address: .init(littleEndian: bytes.address), + addressType: AddressType(rawValue: bytes.type) ?? .lowEnergyPublic + ) + } +} diff --git a/Sources/BluetoothLinux/Internal/CInterop.swift b/Sources/BluetoothLinux/Internal/CInterop.swift index bddf54c..a45cf88 100644 --- a/Sources/BluetoothLinux/Internal/CInterop.swift +++ b/Sources/BluetoothLinux/Internal/CInterop.swift @@ -95,7 +95,37 @@ public extension CInterop { } extension CInterop.SCOSocketAddress: CSocketAddress { - + + @usableFromInline + static var family: SocketAddressFamily { .bluetooth } +} + +public extension CInterop { + + /// `sockaddr_iso` ISO Socket Address + struct ISOSocketAddress: Equatable, Hashable { + + public let family: CInterop.SocketAddressFamily + + /// `bdaddr_t iso_bdaddr` (little endian) + public var address: BluetoothAddress + + /// `uint8_t iso_bdaddr_type` + public var type: UInt8 + + public init( + address: BluetoothAddress = .zero, + type: UInt8 = 0 + ) { + self.family = .init(Self.family.rawValue) + self.address = address + self.type = type + } + } +} + +extension CInterop.ISOSocketAddress: CSocketAddress { + @usableFromInline static var family: SocketAddressFamily { .bluetooth } } diff --git a/Sources/BluetoothLinux/SocketOption.swift b/Sources/BluetoothLinux/SocketOption.swift index ccaf33f..a4b6260 100644 --- a/Sources/BluetoothLinux/SocketOption.swift +++ b/Sources/BluetoothLinux/SocketOption.swift @@ -46,4 +46,7 @@ public enum BluetoothSocketOption: CInt, SocketOptionID { /// Bluetooth Packet Status case packetStatus = 16 // BT_PKT_STATUS + + /// Bluetooth Isochronous Channel Quality of Service + case isoQualityOfService = 17 // BT_ISO_QOS } diff --git a/Sources/BluetoothLinux/SocketProtocol.swift b/Sources/BluetoothLinux/SocketProtocol.swift index ce61176..fb3a415 100644 --- a/Sources/BluetoothLinux/SocketProtocol.swift +++ b/Sources/BluetoothLinux/SocketProtocol.swift @@ -35,6 +35,9 @@ public enum BluetoothSocketProtocol: Int32, Sendable, Codable, CaseIterable { /// Audio/video data transport protocol case avdtp = 7 + + /// Isochronous channels (LE Audio) + case iso = 8 } extension BluetoothSocketProtocol: SocketProtocol { @@ -53,6 +56,7 @@ extension BluetoothSocketProtocol: SocketProtocol { case .cmtp: return .raw case .hidp: return .raw case .avdtp: return .raw + case .iso: return .sequencedPacket } } } diff --git a/Tests/BluetoothLinuxTests/ISOTests.swift b/Tests/BluetoothLinuxTests/ISOTests.swift new file mode 100644 index 0000000..24c4e1f --- /dev/null +++ b/Tests/BluetoothLinuxTests/ISOTests.swift @@ -0,0 +1,72 @@ +// +// ISOTests.swift +// BluetoothLinuxTests +// + +import Foundation +import XCTest +import Bluetooth +import SystemPackage +import Socket +@testable import BluetoothLinux + +final class ISOTests: XCTestCase { + + func testSocketProtocol() { + XCTAssertEqual(BluetoothSocketProtocol.iso.rawValue, 8) // BTPROTO_ISO + XCTAssertEqual(BluetoothSocketProtocol.iso.type, .sequencedPacket) + XCTAssertEqual(BluetoothSocketOption.isoQualityOfService.rawValue, 17) // BT_ISO_QOS + } + + func testQualityOfServiceLayout() { + // must match the kernel's `bt_iso_ucast_qos` layout + // cig, cis, sca, packing, framing (5 bytes), in at offset 8, out at offset 20 + XCTAssertEqual(MemoryLayout.size, 30) + XCTAssertEqual(MemoryLayout.stride, 32) + XCTAssertEqual(MemoryLayout.size, 10) + XCTAssertEqual(MemoryLayout.stride, 12) + XCTAssertEqual(MemoryLayout.offset(of: \.input), 8) + XCTAssertEqual(MemoryLayout.offset(of: \.output), 20) + } + + func testQualityOfServiceEncoding() { + var qos = BluetoothSocketOption.ISOQualityOfService() + XCTAssertEqual(qos.group, 0xFF) // unset + XCTAssertEqual(qos.stream, 0xFF) // unset + qos.group = 1 + qos.stream = 2 + qos.output = .init(interval: 10_000, latency: 10, sdu: 40, phy: 0x02, retransmissionNumber: 2) + qos.withUnsafeBytes { buffer in + XCTAssertEqual(buffer[0], 1) // cig + XCTAssertEqual(buffer[1], 2) // cis + // out.interval at offset 20 (10000 = 0x2710 little endian) + XCTAssertEqual(buffer[20], 0x10) + XCTAssertEqual(buffer[21], 0x27) + // out.sdu at offset 26 + XCTAssertEqual(buffer[26], 40) + } + let decoded = BluetoothSocketOption.ISOQualityOfService.withUnsafeBytes { buffer in + buffer[0] = 5 // cig + buffer[8] = 0x10 // in.interval + buffer[9] = 0x27 + } + XCTAssertEqual(decoded.group, 5) + XCTAssertEqual(decoded.input.interval, 10_000) + } + + #if os(Linux) + func testSocketAddressEncoding() { + // socket address construction requires the Bluetooth address family (Linux only) + let address = ISOSocketAddress( + address: BluetoothAddress(rawValue: "00:1A:7D:DA:71:13")!, + addressType: .lowEnergyRandom + ) + address.withUnsafePointer { pointer, size in + XCTAssertEqual(Int(size), MemoryLayout.size) + let bytes = UnsafeRawPointer(pointer).loadUnaligned(as: CInterop.ISOSocketAddress.self) + XCTAssertEqual(BluetoothAddress(littleEndian: bytes.address).rawValue, "00:1A:7D:DA:71:13") + XCTAssertEqual(bytes.type, AddressType.lowEnergyRandom.rawValue) + } + } + #endif +}