From 173ba449bc9f73711e27910da0669f388d5000a9 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 01:59:57 -0400 Subject: [PATCH 1/2] Add classic L2CAP connection conveniences --- .../L2CAP/L2CAPSocket+Classic.swift | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 Sources/BluetoothLinux/L2CAP/L2CAPSocket+Classic.swift diff --git a/Sources/BluetoothLinux/L2CAP/L2CAPSocket+Classic.swift b/Sources/BluetoothLinux/L2CAP/L2CAPSocket+Classic.swift new file mode 100644 index 0000000..774b2fa --- /dev/null +++ b/Sources/BluetoothLinux/L2CAP/L2CAPSocket+Classic.swift @@ -0,0 +1,164 @@ +// +// L2CAPSocket+Classic.swift +// BluetoothLinux +// +// Classic (BR/EDR) L2CAP connection conveniences. +// + +import Foundation +import Bluetooth +import BluetoothHCI +import SystemPackage +import Socket + +public extension L2CAPSocket { + + /// Creates a client socket for a classic (BR/EDR) L2CAP connection. + /// + /// - Parameter protocolServiceMultiplexer: The protocol/service multiplexer (PSM) + /// of the service on the remote device (e.g. ``ProtocolServiceMultiplexer/bnep``). + static func classicClient( + address localAddress: BluetoothAddress, + destination destinationAddress: BluetoothAddress, + protocolServiceMultiplexer: ProtocolServiceMultiplexer + ) throws(Errno) -> Self { + let localSocketAddress = L2CAPSocketAddress( + address: localAddress, + addressType: nil, + protocolServiceMultiplexer: nil, + channel: 0 + ) + let destinationSocketAddress = L2CAPSocketAddress( + address: destinationAddress, + addressType: nil, + protocolServiceMultiplexer: protocolServiceMultiplexer, + channel: 0 + ) + return try connect( + local: localSocketAddress, + destination: destinationSocketAddress + ) + } + + /// Creates a client socket for a classic (BR/EDR) L2CAP connection. + static func classicClient( + hostController: HostController, + destination: BluetoothAddress, + protocolServiceMultiplexer: ProtocolServiceMultiplexer + ) async throws -> Self { + let localAddress = try await hostController.readDeviceAddress() + return try classicClient( + address: localAddress, + destination: destination, + protocolServiceMultiplexer: protocolServiceMultiplexer + ) + } + + /// Creates a server socket for a classic (BR/EDR) L2CAP connection. + static func classicServer( + address: BluetoothAddress, + protocolServiceMultiplexer: ProtocolServiceMultiplexer, + backlog: Int = Socket.maxBacklog + ) throws(Errno) -> Self { + let address = L2CAPSocketAddress( + address: address, + addressType: nil, + protocolServiceMultiplexer: protocolServiceMultiplexer, + channel: 0 + ) + let fileDescriptor = try SocketDescriptor.l2cap(address, [.closeOnExec, .nonBlocking]) + try fileDescriptor.closeIfThrows { () throws(Errno) -> () in + try fileDescriptor.listen(backlog: backlog) + } + return Self.init(fileDescriptor: fileDescriptor, address: address) + } + + /// Creates a server socket for a classic (BR/EDR) L2CAP connection. + static func classicServer( + hostController: HostController, + protocolServiceMultiplexer: ProtocolServiceMultiplexer, + backlog: Int = Socket.maxBacklog + ) async throws -> Self { + let address = try await hostController.readDeviceAddress() + return try classicServer( + address: address, + protocolServiceMultiplexer: protocolServiceMultiplexer, + backlog: backlog + ) + } + + /// Creates a socket and connects to the destination, waiting until the + /// non-blocking connection completes. + internal static func connect( + local localSocketAddress: L2CAPSocketAddress, + destination destinationSocketAddress: L2CAPSocketAddress + ) throws(Errno) -> Self { + let fileDescriptor = try SocketDescriptor.l2cap(localSocketAddress, [.closeOnExec, .nonBlocking]) + + // 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) + } +} + +// MARK: - Server + +public extension L2CAPSocket.Server { + + /// Creates a server socket for a classic (BR/EDR) L2CAP connection. + static func classicServer( + address: BluetoothAddress, + protocolServiceMultiplexer: ProtocolServiceMultiplexer, + backlog: Int = Socket.maxBacklog + ) throws(Errno) -> L2CAPSocket.Server { + let socket = try L2CAPSocket.classicServer( + address: address, + protocolServiceMultiplexer: protocolServiceMultiplexer, + backlog: backlog + ) + return Self.init(socket: socket) + } +} + +// MARK: - Connection + +public extension L2CAPSocket.Connection { + + /// Creates a client socket for a classic (BR/EDR) L2CAP connection. + static func classicClient( + address: BluetoothAddress, + destination: BluetoothAddress, + protocolServiceMultiplexer: ProtocolServiceMultiplexer + ) throws(Errno) -> Self { + let socket = try L2CAPSocket.classicClient( + address: address, + destination: destination, + protocolServiceMultiplexer: protocolServiceMultiplexer + ) + return .init(socket: socket, destination: destination) + } +} From e85ffef1ba89a627ebcac5bc949112256494905c Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 01:59:57 -0400 Subject: [PATCH 2/2] Add L2CAP socket address tests --- .../L2CAPSocketAddressTests.swift | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 Tests/BluetoothLinuxTests/L2CAPSocketAddressTests.swift diff --git a/Tests/BluetoothLinuxTests/L2CAPSocketAddressTests.swift b/Tests/BluetoothLinuxTests/L2CAPSocketAddressTests.swift new file mode 100644 index 0000000..8ac1a16 --- /dev/null +++ b/Tests/BluetoothLinuxTests/L2CAPSocketAddressTests.swift @@ -0,0 +1,60 @@ +// +// L2CAPSocketAddressTests.swift +// BluetoothLinuxTests +// +// Socket address construction requires the Bluetooth address family, +// which is only available on Linux (these run in CI). +// + +#if os(Linux) +import Foundation +import XCTest +import Bluetooth +import BluetoothHCI +import SystemPackage +import Socket +@testable import BluetoothLinux + +final class L2CAPSocketAddressTests: XCTestCase { + + func testClassicAddressEncoding() { + let address = L2CAPSocketAddress( + address: BluetoothAddress(rawValue: "00:1A:7D:DA:71:13")!, + addressType: nil, + protocolServiceMultiplexer: .bnep, + channel: 0 + ) + address.withUnsafePointer { pointer, size in + XCTAssertEqual(Int(size), MemoryLayout.size) + let bytes = UnsafeRawPointer(pointer).loadUnaligned(as: CInterop.L2CAPSocketAddress.self) + XCTAssertEqual(UInt16(littleEndian: bytes.l2_psm), 0x000F) + XCTAssertEqual(bytes.l2_cid, 0) + XCTAssertEqual(bytes.l2_bdaddr_type, 0) + XCTAssertEqual(BluetoothAddress(littleEndian: bytes.l2_bdaddr).rawValue, "00:1A:7D:DA:71:13") + } + } + + func testClassicAddressDecoding() { + var bytes = CInterop.L2CAPSocketAddress() + bytes.l2_psm = UInt16(0x0011).littleEndian // HID Control + bytes.l2_bdaddr = BluetoothAddress(rawValue: "00:1A:7D:DA:71:13")!.littleEndian + let address = L2CAPSocketAddress(bytes) + XCTAssertEqual(address.protocolServiceMultiplexer, .hidc) + XCTAssertEqual(address.addressType, nil) + XCTAssertEqual(address.channel.rawValue, 0) + XCTAssertEqual(address.address.rawValue, "00:1A:7D:DA:71:13") + } + + func testLowEnergyAddressEncoding() { + let address = L2CAPSocketAddress( + lowEnergy: BluetoothAddress(rawValue: "00:1A:7D:DA:71:13")!, + isRandom: false + ) + address.withUnsafePointer { pointer, size in + let bytes = UnsafeRawPointer(pointer).loadUnaligned(as: CInterop.L2CAPSocketAddress.self) + XCTAssertEqual(UInt16(littleEndian: bytes.l2_cid), 0x0004) // ATT + XCTAssertEqual(bytes.l2_bdaddr_type, AddressType.lowEnergyPublic.rawValue) + } + } +} +#endif