From 55c6fbbf923bde2dbc00b425988b1977a8bf990d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 21:23:03 -0400 Subject: [PATCH 01/29] Add code coverage script --- Scripts/coverage.sh | 199 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100755 Scripts/coverage.sh diff --git a/Scripts/coverage.sh b/Scripts/coverage.sh new file mode 100755 index 0000000..bf4aa18 --- /dev/null +++ b/Scripts/coverage.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# +# coverage.sh — run a SwiftPM test suite with code coverage, export LCOV and +# Cobertura reports, and enforce a minimum line-coverage threshold. +# +# Coverage is measured against the package's OWN sources only (everything under +# the repo's `Sources/` directory). Dependencies (checked out under `.build`), +# generated sources, and the test target are excluded, so the number reflects +# the coverage of this package's code rather than being diluted by third-party +# code that the tests happen to exercise. +# +# The Cobertura XML (coverage.xml) is the format GitHub Code Quality consumes via +# `actions/upload-code-coverage`; the LCOV report (coverage.lcov) is kept for +# other tools (Codecov, Coveralls, editors) that prefer it. +# +# Usage: +# Scripts/coverage.sh [threshold] +# +# Environment variables: +# COVERAGE_THRESHOLD Minimum line coverage percentage (default: 1). +# The suite is small today (most of this package needs +# real Bluetooth hardware); ratchet this up as tests grow. +# Overridden by the optional [threshold] argument. +# COVERAGE_SOURCE_PREFIX Absolute path prefix selecting the sources that count +# toward coverage (default: "$PWD/Sources/"). Narrow it +# to a single target, e.g. "$PWD/Sources/MyLibrary/", to +# gate on one target instead of every source file. +# COVERAGE_OUTPUT LCOV output file (default: .build/coverage/coverage.lcov). +# COBERTURA_OUTPUT Cobertura XML output file (default: .build/coverage/coverage.xml). +# SKIP_TEST If set to 1, reuse existing coverage data instead of +# re-running `swift test` (useful while iterating). + +set -euo pipefail + +THRESHOLD="${1:-${COVERAGE_THRESHOLD:-1}}" +SOURCE_PREFIX="${COVERAGE_SOURCE_PREFIX:-$PWD/Sources/}" +OUTPUT="${COVERAGE_OUTPUT:-.build/coverage/coverage.lcov}" +COBERTURA_OUTPUT="${COBERTURA_OUTPUT:-.build/coverage/coverage.xml}" + +# Resolve the correct llvm-cov (xcrun on Apple platforms, plain llvm-cov elsewhere). +if command -v xcrun >/dev/null 2>&1; then + LLVM_COV="xcrun llvm-cov" +else + LLVM_COV="llvm-cov" +fi + +# 1. Run the tests with coverage instrumentation. +if [ "${SKIP_TEST:-0}" != "1" ]; then + echo "==> Running tests with code coverage" + swift test --enable-code-coverage +fi + +# 2. Locate the coverage artifacts SwiftPM produced. +CODECOV_JSON="$(swift test --enable-code-coverage --show-codecov-path)" +COV_DIR="$(dirname "$CODECOV_JSON")" +PROFDATA="$COV_DIR/default.profdata" + +if [ ! -f "$CODECOV_JSON" ]; then + echo "error: coverage report not found at $CODECOV_JSON" >&2 + exit 1 +fi + +# 3. Locate the instrumented test binary (differs by platform: on macOS it lives +# inside the .xctest bundle, on Linux the .xctest file is the binary itself). +BIN_PATH="$(swift build --show-bin-path)" +TEST_BINARY="" +for candidate in \ + "$BIN_PATH"/*PackageTests.xctest/Contents/MacOS/*PackageTests \ + "$BIN_PATH"/*PackageTests.xctest; do + if [ -f "$candidate" ]; then + TEST_BINARY="$candidate" + break + fi +done + +# 4. Export an LCOV report (for Codecov / Coveralls / editors). +mkdir -p "$(dirname "$OUTPUT")" +if [ -n "$TEST_BINARY" ] && [ -f "$PROFDATA" ]; then + echo "==> Exporting LCOV report to $OUTPUT" + $LLVM_COV export \ + -format=lcov \ + -instr-profile "$PROFDATA" \ + "$TEST_BINARY" \ + -ignore-filename-regex='.build/(checkouts|.*\.build)/|Tests/|\.derived/|DerivedSources/' \ + > "$OUTPUT" +else + echo "warning: could not locate test binary or profdata; skipping LCOV export" >&2 +fi + +# 5. Generate a Cobertura XML report (for GitHub Code Quality / upload-code-coverage). +echo "==> Writing Cobertura report to $COBERTURA_OUTPUT" +mkdir -p "$(dirname "$COBERTURA_OUTPUT")" +python3 - "$CODECOV_JSON" "$SOURCE_PREFIX" "$PWD" "$COBERTURA_OUTPUT" <<'PY' +import json, os, sys, time +from xml.sax.saxutils import escape, quoteattr + +report_path, source_prefix, repo_root, output = sys.argv[1:5] + +with open(report_path) as f: + report = json.load(f) + +files = [] +total_covered = total_lines = 0 +for file in report["data"][0]["files"]: + name = file["filename"] + if not name.startswith(source_prefix): + continue + # Per-line hit count: the greatest region count starting on each line (a line + # is covered if any region on it executed, so branch sub-regions don't hide it). + line_hits = {} + for seg in file["segments"]: + line, count, has_count = seg[0], seg[2], seg[3] + if has_count: + line_hits[line] = max(line_hits.get(line, 0), count) + covered = sum(1 for h in line_hits.values() if h > 0) + total_covered += covered + total_lines += len(line_hits) + files.append((os.path.relpath(name, repo_root), line_hits, covered, len(line_hits))) + +def rate(c, t): + return c / t if t else 0.0 + +overall = rate(total_covered, total_lines) +timestamp = int(time.time()) + +out = [ + '', + '', + '' + % (overall, total_covered, total_lines, timestamp), + " ", + " %s" % escape(repo_root), + " ", + " ", + ' ' + % (escape(os.path.basename(repo_root)), overall), + " ", +] +for rel, line_hits, covered, total in sorted(files): + out.append( + ' ' + % (quoteattr(os.path.basename(rel)), quoteattr(rel), rate(covered, total)) + ) + out.append(" ") + out.append(" ") + for line in sorted(line_hits): + out.append(' ' % (line, line_hits[line])) + out.append(" ") + out.append(" ") +out += [" ", " ", " ", ""] + +with open(output, "w") as f: + f.write("\n".join(out) + "\n") + +print(" %d/%d lines (%.2f%%)" % (total_covered, total_lines, 100 * overall)) +PY + +# 6. Compute line coverage for the package sources and enforce the threshold. +echo "==> Computing coverage for ${SOURCE_PREFIX}" +python3 - "$CODECOV_JSON" "$SOURCE_PREFIX" "$THRESHOLD" "$PWD" <<'PY' +import json, os, sys + +report_path, source_prefix, threshold, repo_root = sys.argv[1], sys.argv[2], float(sys.argv[3]), sys.argv[4] + +with open(report_path) as f: + report = json.load(f) + +covered = total = 0 +rows = [] +for file in report["data"][0]["files"]: + name = file["filename"] + if not name.startswith(source_prefix): + continue + lines = file["summary"]["lines"] + covered += lines["covered"] + total += lines["count"] + rows.append((lines["percent"], os.path.relpath(name, repo_root))) + +if total == 0: + print("error: no source files matched prefix %r" % source_prefix, file=sys.stderr) + print(" (set COVERAGE_SOURCE_PREFIX to your package's Sources path)", file=sys.stderr) + sys.exit(1) + +percent = 100.0 * covered / total + +for pct, name in sorted(rows): + print(" %6.2f%% %s" % (pct, name)) + +print("-" * 48) +print("Total line coverage: %.2f%% (%d/%d lines)" % (percent, covered, total)) +print("Required threshold: %.2f%%" % threshold) + +if percent < threshold: + print("FAILED: coverage %.2f%% is below the %.2f%% threshold" % (percent, threshold), file=sys.stderr) + sys.exit(1) + +print("PASSED: coverage meets the threshold") +PY From 7ebd7b969b1e325bed63dc72dff96c42cc9ba9aa Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 21:23:03 -0400 Subject: [PATCH 02/29] Wire code coverage into CI --- .github/workflows/swift.yml | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 7628958..1346058 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -6,11 +6,14 @@ jobs: linux-swift: name: Linux - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest container: swift:6.0.2-jammy + permissions: + contents: read + code-quality: write # required by actions/upload-code-coverage steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Swift Version run: swift --version - name: Build (Debug) @@ -20,9 +23,28 @@ jobs: - name: Test (Debug) run: swift test --configuration debug --enable-code-coverage - name: Archive Build artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: swiftpm-build-ubuntu-x86_64 path: .build/*/*.xctest - - name: Coverage Report - uses: maxep/spm-lcov-action@0.3.1 + - name: Install coverage prerequisites + run: apt-get update && apt-get install -y python3 + - name: Export coverage and enforce threshold + run: SKIP_TEST=1 Scripts/coverage.sh + - name: Upload coverage to GitHub Code Quality + uses: actions/upload-code-coverage@v1 + with: + file: .build/coverage/coverage.xml + language: Swift + label: code-coverage/llvm-cov + # Code Quality was in public preview until 2026-07-20; keep this while + # the repo might not have the feature enabled, so a failed upload + # doesn't break CI. + fail-on-error: false + - name: Upload coverage artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: code-coverage + path: .build/coverage/ + if-no-files-found: error From c7e3fa3c365e80cf6ac0757ac9438eecb8676bf8 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 21:23:03 -0400 Subject: [PATCH 03/29] Pin toolchain to Swift 6.3.3 --- .swift-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .swift-version diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..d9b300f --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +6.3.3 \ No newline at end of file From a839960620df2dba4f7812fb82582f3942627c3a Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 21:23:03 -0400 Subject: [PATCH 04/29] Enable syscall mocking in debug builds --- Package.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index a85595f..8bf0245 100644 --- a/Package.swift +++ b/Package.swift @@ -48,6 +48,11 @@ var package = Package( package: "Socket" ), "CBluetoothLinux" + ], + swiftSettings: [ + // Syscall mocking for unit tests (see Internal/Mocking.swift), + // same pattern as swift-system. + .define("ENABLE_MOCKING", .when(configuration: .debug)) ] ), .target( @@ -70,7 +75,10 @@ var package = Package( package: "Bluetooth" ) ], - swiftSettings: [.swiftLanguageMode(.v5)] + swiftSettings: [ + .swiftLanguageMode(.v5), + .define("ENABLE_MOCKING", .when(configuration: .debug)) + ] ) ] ) From 00a41f1c354ffa18211fc183ae2fb39936e2ffcd Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 21:23:03 -0400 Subject: [PATCH 05/29] Add syscall mocking driver --- Sources/BluetoothLinux/Internal/Mocking.swift | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 Sources/BluetoothLinux/Internal/Mocking.swift diff --git a/Sources/BluetoothLinux/Internal/Mocking.swift b/Sources/BluetoothLinux/Internal/Mocking.swift new file mode 100644 index 0000000..0e49a89 --- /dev/null +++ b/Sources/BluetoothLinux/Internal/Mocking.swift @@ -0,0 +1,154 @@ +// +// Mocking.swift +// BluetoothLinux +// +// Syscall mocking support, modeled after Swift System's +// `Sources/System/Internals/Mocking.swift`. +// +// Mocking is contextual, accessible through `MockingDriver.withMockingEnabled`. +// Mocking state, including whether it is enabled, is stored in thread-local +// storage. Mocking is only compiled into debug builds (`ENABLE_MOCKING`), +// so release builds pay no runtime overhead. +// + +#if ENABLE_MOCKING +#if canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif + +internal struct Trace { + + internal struct Entry: Equatable, Hashable { + + internal var name: String + internal var arguments: [AnyHashable] + + internal init(name: String, _ arguments: [AnyHashable]) { + self.name = name + self.arguments = arguments + } + } + + private var entries: [Entry] = [] + private var firstEntry: Int = 0 + + internal var isEmpty: Bool { firstEntry >= entries.count } + + internal mutating func dequeue() -> Entry? { + guard !self.isEmpty else { return nil } + defer { firstEntry += 1 } + return entries[firstEntry] + } + + fileprivate mutating func add(_ e: Entry) { + entries.append(e) + } +} + +internal enum ForceErrno: Equatable { + case none + case always(errno: CInt) + case counted(errno: CInt, count: Int) +} + +// Provide access to the driver, context, and trace stack of mocking +internal final class MockingDriver { + + /// Record syscalls and their arguments + internal var trace = Trace() + + /// Mock errors inside syscalls + internal var forceErrno = ForceErrno.none + + /// Handler for mocked `ioctl()` calls, invoked after `forceErrno` is + /// applied. Use it to populate the request's out-parameter (the same + /// pointer the kernel would write to) and choose the return value; a + /// handler returning -1 is responsible for setting `errno`. When `nil`, + /// mocked calls succeed without touching the request. + internal var ioctlHandler: ((_ fd: CInt, _ request: CUnsignedLong, _ pointer: UnsafeMutableRawPointer?) -> CInt)? = nil +} + +private let driverKey: pthread_key_t = { + var raw = pthread_key_t() + guard 0 == pthread_key_create(&raw, nil) else { + fatalError("Unable to create TLS key") + } + return raw +}() + +internal var currentMockingDriver: MockingDriver? { + guard let rawPtr = pthread_getspecific(driverKey) else { return nil } + return Unmanaged.fromOpaque(rawPtr).takeUnretainedValue() +} + +extension MockingDriver { + + /// Enables mocking for the duration of `f` with a clean trace queue. + /// Restores prior mocking status and trace queue after execution. + internal static func withMockingEnabled( + _ f: (MockingDriver) throws -> R + ) rethrows -> R { + let priorMocking = currentMockingDriver + let driver = MockingDriver() + + defer { + if let object = priorMocking { + pthread_setspecific(driverKey, Unmanaged.passUnretained(object).toOpaque()) + } else { + pthread_setspecific(driverKey, nil) + } + _fixLifetime(driver) + } + + pthread_setspecific(driverKey, Unmanaged.passUnretained(driver).toOpaque()) + return try f(driver) + } + + internal static var enabled: Bool { mockingEnabled } +} +#endif // ENABLE_MOCKING + +@inline(__always) +internal var mockingEnabled: Bool { + // Fast constant-foldable check for release builds + #if ENABLE_MOCKING + return currentMockingDriver != nil + #else + return false + #endif +} + +#if ENABLE_MOCKING +internal func _mockIOCTL( + _ fd: CInt, + _ request: CUnsignedLong, + _ pointer: UnsafeMutableRawPointer?, + arguments: [AnyHashable] +) -> CInt { + precondition(mockingEnabled) + guard let driver = currentMockingDriver else { + fatalError("Mocking requested from non-mocking context") + } + driver.trace.add(Trace.Entry(name: "ioctl", arguments)) + + switch driver.forceErrno { + case .none: + break + case .always(let e): + errno = e + return -1 + case .counted(let e, let count): + assert(count >= 1) + errno = e + driver.forceErrno = count > 1 ? .counted(errno: e, count: count - 1) : .none + return -1 + } + + if let handler = driver.ioctlHandler { + return handler(fd, request, pointer) + } + return 0 +} +#endif // ENABLE_MOCKING From 13b82c72ddf7b38ca355676c1a024da9de2b961d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 21:23:03 -0400 Subject: [PATCH 06/29] Add mockable ioctl syscall shims --- .../BluetoothLinux/Internal/Syscalls.swift | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 Sources/BluetoothLinux/Internal/Syscalls.swift diff --git a/Sources/BluetoothLinux/Internal/Syscalls.swift b/Sources/BluetoothLinux/Internal/Syscalls.swift new file mode 100644 index 0000000..ce313ce --- /dev/null +++ b/Sources/BluetoothLinux/Internal/Syscalls.swift @@ -0,0 +1,74 @@ +// +// Syscalls.swift +// BluetoothLinux +// +// Mockable syscall shims, modeled after Swift System's +// `Sources/System/Internals/Syscalls.swift`. +// +// Every direct syscall this package makes should go through a `system_` +// wrapper here so unit tests can intercept it via `MockingDriver` +// (see Mocking.swift). In release builds these compile down to the +// plain syscall. +// + +import SystemPackage + +#if canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif + +// ioctl +internal func system_ioctl( + _ fd: CInt, + _ request: CUnsignedLong +) -> CInt { + #if ENABLE_MOCKING + if mockingEnabled { return _mockIOCTL(fd, request, nil, arguments: [fd, request]) } + #endif + return ioctl(fd, request) +} + +// ioctl +internal func system_ioctl( + _ fd: CInt, + _ request: CUnsignedLong, + _ value: CInt +) -> CInt { + #if ENABLE_MOCKING + if mockingEnabled { return _mockIOCTL(fd, request, nil, arguments: [fd, request, value]) } + #endif + return ioctl(fd, request, value) +} + +// ioctl +internal func system_ioctl( + _ fd: CInt, + _ request: CUnsignedLong, + _ pointer: UnsafeMutableRawPointer +) -> CInt { + #if ENABLE_MOCKING + if mockingEnabled { return _mockIOCTL(fd, request, pointer, arguments: [fd, request]) } + #endif + return ioctl(fd, request, pointer) +} + +/// Run a syscall returning `-1` on failure, mapping the result into +/// `Result<(), Errno>` and retrying on `EINTR` if requested. +internal func nothingOrErrno( + retryOnInterrupt: Bool, + _ syscall: () -> CInt +) -> Result<(), Errno> { + repeat { + switch syscall() { + case -1: + let error = Errno(rawValue: errno) + guard retryOnInterrupt && error == .interrupted else { + return .failure(error) + } + default: + return .success(()) + } + } while true +} From 3bc5f123b149a57bdaf098143346db093741a0ac Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 21:23:03 -0400 Subject: [PATCH 07/29] Route inputOutput through mockable ioctl seam --- .../BluetoothLinux/Internal/IOControl.swift | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 Sources/BluetoothLinux/Internal/IOControl.swift diff --git a/Sources/BluetoothLinux/Internal/IOControl.swift b/Sources/BluetoothLinux/Internal/IOControl.swift new file mode 100644 index 0000000..81b16c2 --- /dev/null +++ b/Sources/BluetoothLinux/Internal/IOControl.swift @@ -0,0 +1,51 @@ +// +// IOControl.swift +// BluetoothLinux +// +// Internal `ioctl` entry points for this package. These intentionally +// shadow `Socket.SocketDescriptor.inputOutput` (same signatures) so that +// every unqualified `inputOutput` call in this module resolves here and +// routes through the mockable `system_ioctl` shim (see Syscalls.swift), +// allowing unit tests to intercept ioctl calls via `MockingDriver`. +// + +import SystemPackage +import Socket + +internal extension SocketDescriptor { + + /// Manipulates the underlying device parameters of special files. + @usableFromInline + func inputOutput( + _ request: T, + retryOnInterrupt: Bool = true + ) throws(Errno) { + try nothingOrErrno(retryOnInterrupt: retryOnInterrupt) { + system_ioctl(rawValue, request.rawValue) + }.get() + } + + /// Manipulates the underlying device parameters of special files. + @usableFromInline + func inputOutput( + _ request: T, + retryOnInterrupt: Bool = true + ) throws(Errno) { + try nothingOrErrno(retryOnInterrupt: retryOnInterrupt) { + system_ioctl(rawValue, T.id.rawValue, request.intValue) + }.get() + } + + /// Manipulates the underlying device parameters of special files. + @usableFromInline + func inputOutput( + _ request: inout T, + retryOnInterrupt: Bool = true + ) throws(Errno) { + try nothingOrErrno(retryOnInterrupt: retryOnInterrupt) { + request.withUnsafeMutablePointer { pointer in + system_ioctl(rawValue, T.id.rawValue, pointer) + } + }.get() + } +} From 8d8b1f029c6537503039fb5657fde9fe580cbadf Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 21:23:03 -0400 Subject: [PATCH 08/29] Add unit tests for ioctl calls --- .../BluetoothLinuxTests/IOControlTests.swift | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 Tests/BluetoothLinuxTests/IOControlTests.swift diff --git a/Tests/BluetoothLinuxTests/IOControlTests.swift b/Tests/BluetoothLinuxTests/IOControlTests.swift new file mode 100644 index 0000000..2681757 --- /dev/null +++ b/Tests/BluetoothLinuxTests/IOControlTests.swift @@ -0,0 +1,128 @@ +// +// IOControlTests.swift +// BluetoothLinuxTests +// +// Unit tests for ioctl() calls using the syscall mocking scaffolding +// (see Sources/BluetoothLinux/Internal/Mocking.swift), modeled after +// swift-system's MockingTest / TestingInfrastructure. +// +// These run without Bluetooth hardware on both Linux and Darwin: +// `MockingDriver.withMockingEnabled` intercepts `system_ioctl` on the +// current thread, records a trace of (fd, request, argument), and lets +// the test fake the kernel's reply or force an errno. +// + +#if ENABLE_MOCKING +import Foundation +import XCTest +import Bluetooth +import BluetoothHCI +import SystemPackage +import Socket +@testable import BluetoothLinux +#if canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif + +final class IOControlTests: XCTestCase { + + /// A fake file descriptor; mocked syscalls never reach the kernel. + private let fileDescriptor = SocketDescriptor(rawValue: 3) + + func testDeviceUpIsTraced() throws { + try MockingDriver.withMockingEnabled { driver in + try fileDescriptor.deviceUp(for: .init(rawValue: 1)) + XCTAssertEqual( + driver.trace.dequeue(), + Trace.Entry(name: "ioctl", [ + fileDescriptor.rawValue, + HostControllerIO.deviceUp.rawValue, + Int32(1) + ]) + ) + XCTAssertTrue(driver.trace.isEmpty) + } + } + + func testDeviceListFakedKernelReply() throws { + try MockingDriver.withMockingEnabled { driver in + driver.ioctlHandler = { fd, request, pointer in + XCTAssertEqual(fd, self.fileDescriptor.rawValue) + XCTAssertEqual(request, HostControllerIO.getDeviceList.rawValue) + guard let pointer else { + XCTFail("Expected an out-parameter for HCIGETDEVLIST") + errno = EINVAL + return -1 + } + // Write the reply the way the kernel would: dev_num followed + // by hci_dev_req entries (uint16 dev_id, uint32 dev_opt at + // 4-byte alignment — the C layout of hci_dev_list_req). + pointer.storeBytes(of: UInt16(2), as: UInt16.self) + pointer.storeBytes( + of: CInterop.HCIDeviceList.Element(id: 0, options: 0), + toByteOffset: 4, + as: CInterop.HCIDeviceList.Element.self + ) + pointer.storeBytes( + of: CInterop.HCIDeviceList.Element(id: 5, options: 0), + toByteOffset: 4 + MemoryLayout.stride, + as: CInterop.HCIDeviceList.Element.self + ) + return 0 + } + let deviceList = try fileDescriptor.deviceList() + XCTAssertEqual(deviceList.count, 2) + XCTAssertEqual(deviceList[0].id, HostController.ID(rawValue: 0)) + XCTAssertEqual(deviceList[1].id, HostController.ID(rawValue: 5)) + XCTAssertEqual( + driver.trace.dequeue(), + Trace.Entry(name: "ioctl", [ + fileDescriptor.rawValue, + HostControllerIO.getDeviceList.rawValue + ]) + ) + XCTAssertTrue(driver.trace.isEmpty) + } + } + + func testForcedErrnoThrows() { + MockingDriver.withMockingEnabled { driver in + driver.forceErrno = .always(errno: ENODEV) + do { + try fileDescriptor.deviceUp(for: .init(rawValue: 0)) + XCTFail("Expected ENODEV to be thrown") + } catch { + XCTAssertEqual(error as? Errno, Errno(rawValue: ENODEV)) + } + XCTAssertNotNil(driver.trace.dequeue()) + XCTAssertTrue(driver.trace.isEmpty) + } + } + + func testInterruptedCallIsRetried() { + MockingDriver.withMockingEnabled { driver in + driver.forceErrno = .counted(errno: EINTR, count: 2) + do { + try fileDescriptor.deviceUp(for: .init(rawValue: 0)) + } catch { + XCTFail("EINTR should have been retried, got \(error)") + } + // 2 interrupted attempts + 1 success + XCTAssertNotNil(driver.trace.dequeue()) + XCTAssertNotNil(driver.trace.dequeue()) + XCTAssertNotNil(driver.trace.dequeue()) + XCTAssertTrue(driver.trace.isEmpty) + } + } + + func testMockingIsScoped() { + XCTAssertFalse(mockingEnabled) + MockingDriver.withMockingEnabled { _ in + XCTAssertTrue(mockingEnabled) + } + XCTAssertFalse(mockingEnabled) + } +} +#endif // ENABLE_MOCKING From 255aade2578ff53ed3fc153b7c1367b49b5459bc Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 21:51:10 -0400 Subject: [PATCH 09/29] Migrate deviceCommand to new HCICommandParameter encoding --- Sources/BluetoothLinux/HCI/DeviceCommand.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/BluetoothLinux/HCI/DeviceCommand.swift b/Sources/BluetoothLinux/HCI/DeviceCommand.swift index 99f5b4a..2500980 100644 --- a/Sources/BluetoothLinux/HCI/DeviceCommand.swift +++ b/Sources/BluetoothLinux/HCI/DeviceCommand.swift @@ -19,7 +19,7 @@ public extension HostController { func deviceCommand(_ commandParameter: T) async throws { try await socket.sendCommand( T.command, - parameter: commandParameter.data + parameter: Data(commandParameter) ) } } From be2d85997fdb856865434b7a8d9e9533d390fe2e Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 21:51:10 -0400 Subject: [PATCH 10/29] Migrate deviceRequest to new HCICommandParameter encoding --- Sources/BluetoothLinux/HCI/DeviceRequest.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/BluetoothLinux/HCI/DeviceRequest.swift b/Sources/BluetoothLinux/HCI/DeviceRequest.swift index c786cd4..d300db3 100644 --- a/Sources/BluetoothLinux/HCI/DeviceRequest.swift +++ b/Sources/BluetoothLinux/HCI/DeviceRequest.swift @@ -20,7 +20,7 @@ public extension HostController { ) async throws -> EP { let command = CP.command - let parameterData = commandParameter.data + let parameterData = Data(commandParameter) let responseData = try await socket.sendRequest( command: command, commandParameterData: parameterData, @@ -81,7 +81,7 @@ public extension HostController { let data = try await socket.sendRequest( command: CP.command, - commandParameterData: commandParameter.data, + commandParameterData: Data(commandParameter), eventParameterLength: 1, timeout: timeout ) @@ -127,7 +127,7 @@ public extension HostController { let data = try await socket.sendRequest( command: commandReturnType.command, - commandParameterData: commandParameter.data, + commandParameterData: Data(commandParameter), eventParameterLength: commandReturnType.length + 1, timeout: timeout ) From 422083bdd0276d2516a0d9e143122b00588e63e7 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 21:51:10 -0400 Subject: [PATCH 11/29] Encode HCI command header via append(to:) --- Sources/BluetoothLinux/HCI/HCIFileDescriptor.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/BluetoothLinux/HCI/HCIFileDescriptor.swift b/Sources/BluetoothLinux/HCI/HCIFileDescriptor.swift index 12eecba..32b7383 100644 --- a/Sources/BluetoothLinux/HCI/HCIFileDescriptor.swift +++ b/Sources/BluetoothLinux/HCI/HCIFileDescriptor.swift @@ -28,7 +28,7 @@ internal extension Socket { let dataLength = 1 + HCICommandHeader.length + parameterData.count var data = Data(capacity: dataLength) data.append(HCIPacketType.command.rawValue) - data.append(header.data) + header.append(to: &data) if parameterData.isEmpty == false { data.append(parameterData) } From 4b3b072005c08260db187ca57ca051ae6694ad38 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 21:51:11 -0400 Subject: [PATCH 12/29] Update L2CAP tests for new Bluetooth UUID and GATT API --- Tests/BluetoothLinuxTests/L2CAPTests.swift | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Tests/BluetoothLinuxTests/L2CAPTests.swift b/Tests/BluetoothLinuxTests/L2CAPTests.swift index d31f68b..902e296 100644 --- a/Tests/BluetoothLinuxTests/L2CAPTests.swift +++ b/Tests/BluetoothLinuxTests/L2CAPTests.swift @@ -56,33 +56,33 @@ final class L2CAPTests: XCTestCase { let newConnection = try serverSocket.accept() NSLog("Server Connected") let service = GATTAttribute.Service( - uuid: .deviceInformation, + uuid: .bit16(0x180A), // Device Information isPrimary: true, characteristics: [ GATTAttribute.Characteristic( uuid: GATTManufacturerNameString.uuid, - value: GATTManufacturerNameString(rawValue: "PureSwift").data, + value: Data(GATTManufacturerNameString(rawValue: "PureSwift")), permissions: [.read], properties: [.read], descriptors: [] ), GATTAttribute.Characteristic( uuid: GATTModelNumber.uuid, - value: GATTModelNumber(rawValue: "SolarInverter1,1").data, + value: Data(GATTModelNumber(rawValue: "SolarInverter1,1")), permissions: [.read], properties: [.read], descriptors: [] ), GATTAttribute.Characteristic( uuid: GATTHardwareRevisionString.uuid, - value: GATTHardwareRevisionString(rawValue: "1.0.0").data, + value: Data(GATTHardwareRevisionString(rawValue: "1.0.0")), permissions: [.read], properties: [.read], descriptors: [] ), GATTAttribute.Characteristic( uuid: GATTFirmwareRevisionString.uuid, - value: GATTFirmwareRevisionString(rawValue: "1.0.1").data, + value: Data(GATTFirmwareRevisionString(rawValue: "1.0.1")), permissions: [.read], properties: [.read], descriptors: [] @@ -90,12 +90,12 @@ final class L2CAPTests: XCTestCase { ] ) let service2 = GATTAttribute.Service( - uuid: .savantSystems, + uuid: .bit16(0xFEA8), // Savant Systems isPrimary: true, characteristics: [ GATTAttribute.Characteristic( - uuid: .savantSystems2, - value: GATTManufacturerNameString(rawValue: "PureSwift").data, + uuid: .bit16(0xFEA9), // Savant Systems + value: Data(GATTManufacturerNameString(rawValue: "PureSwift")), permissions: [.read, .write], properties: [.read, .write], descriptors: [] @@ -103,12 +103,12 @@ final class L2CAPTests: XCTestCase { ] ) let batteryService = GATTAttribute.Service( - uuid: .batteryService, + uuid: GATTBatteryService.uuid, isPrimary: true, characteristics: [ GATTAttribute.Characteristic( - uuid: .batteryService, - value: GATTBatteryLevel(level: .init(rawValue: 95)!).data, + uuid: GATTBatteryLevel.uuid, + value: Data(GATTBatteryLevel(level: .init(rawValue: 95)!)), permissions: [.read], properties: [.read], descriptors: [] From 9c3d61ebc473551f9e289313af40f28c6cd72eff Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 22:43:07 -0400 Subject: [PATCH 13/29] Add unit tests for value types --- Tests/BluetoothLinuxTests/ValueTests.swift | 114 +++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 Tests/BluetoothLinuxTests/ValueTests.swift diff --git a/Tests/BluetoothLinuxTests/ValueTests.swift b/Tests/BluetoothLinuxTests/ValueTests.swift new file mode 100644 index 0000000..2f124b4 --- /dev/null +++ b/Tests/BluetoothLinuxTests/ValueTests.swift @@ -0,0 +1,114 @@ +// +// ValueTests.swift +// BluetoothLinuxTests +// +// Unit tests for value types that need no Bluetooth hardware: +// enums, raw-value round trips, and pure conversion logic. +// + +import Foundation +import XCTest +import Bluetooth +import BluetoothHCI +import SystemPackage +import Socket +@testable import BluetoothLinux + +final class ValueTests: XCTestCase { + + func testAddressTypeRawValues() { + XCTAssertEqual(AddressType().rawValue, AddressType.bredr.rawValue) + XCTAssertEqual(AddressType.bredr.rawValue, 0x00) + XCTAssertEqual(AddressType.lowEnergyPublic.rawValue, 0x01) + XCTAssertEqual(AddressType.lowEnergyRandom.rawValue, 0x02) + } + + func testAddressTypeIsLowEnergy() { + XCTAssertFalse(AddressType.bredr.isLowEnergy) + XCTAssertTrue(AddressType.lowEnergyPublic.isLowEnergy) + XCTAssertTrue(AddressType.lowEnergyRandom.isLowEnergy) + } + + func testAddressTypeFromLowEnergy() { + XCTAssertEqual(AddressType(lowEnergy: .public), .lowEnergyPublic) + XCTAssertEqual(AddressType(lowEnergy: .publicIdentity), .lowEnergyPublic) + XCTAssertEqual(AddressType(lowEnergy: .random), .lowEnergyRandom) + XCTAssertEqual(AddressType(lowEnergy: .randomIdentity), .lowEnergyRandom) + } + + func testErrnoFromHCIError() { + XCTAssertEqual(Errno(HCIError.unknownCommand), .badMessage) + XCTAssertEqual(Errno(HCIError.noConnection), .socketNotConnected) + XCTAssertEqual(Errno(HCIError.hardwareFailure), .ioError) + XCTAssertEqual(Errno(HCIError.memoryFull), .noMemory) + XCTAssertEqual(Errno(HCIError.connectionTimeout), .timedOut) + XCTAssertEqual(Errno(HCIError.commandDisallowed), .resourceBusy) + XCTAssertEqual(Errno(HCIError.connectionTerminated), .connectionAbort) + XCTAssertEqual(Errno(HCIError.repeatedAttempts), .tooManySymbolicLinkLevels) + XCTAssertEqual(Errno(HCIError.unsupportedRemoteFeature), .protocolNotSupported) + // A case not handled by the switch maps to the default. + XCTAssertEqual(Errno(HCIError.controllerBusy), .noFunction) + } + + func testHostControllerIORawValueRoundTrip() { + for value in HostControllerIO.allCases { + XCTAssertEqual(HostControllerIO(rawValue: value.rawValue), value) + } + } + + func testHostControllerIOInvalidRawValue() { + XCTAssertNil(HostControllerIO(rawValue: 0)) + } + + func testHostControllerIODescription() { + XCTAssertEqual(HostControllerIO.deviceUp.description, ".deviceUp") + XCTAssertEqual(HostControllerIO.getDeviceList.debugDescription, ".getDeviceList") + } + + func testHostControllerIOAllCases() { + XCTAssertEqual(HostControllerIO.allCases.count, 21) + } + + func testHostControllerIOCodable() throws { + let value = HostControllerIO.getDeviceInfo + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(HostControllerIO.self, from: data) + XCTAssertEqual(decoded, value) + } + + func testRFCOMMIORawValueRoundTrip() { + for value in RFCOMMIO.allCases { + XCTAssertEqual(RFCOMMIO(rawValue: value.rawValue), value) + } + XCTAssertEqual(RFCOMMIO.allCases.count, 4) + XCTAssertEqual(RFCOMMIO.createDevice.description, ".createDevice") + } + + func testHCIDeviceOptionsContains() { + // Flag raw values are bit positions: .up=0, .initialized=1, .running=2. + let options = HCIDeviceOptions(rawValue: 0b0000_0101) + XCTAssertTrue(options.contains(.up)) + XCTAssertFalse(options.contains(.initialized)) + XCTAssertTrue(options.contains(.running)) + + let other = HCIDeviceOptions(rawValue: 0b0000_0010) + XCTAssertFalse(other.contains(.up)) + XCTAssertTrue(other.contains(.initialized)) + XCTAssertFalse(other.contains(.running)) + } + + func testHCIDeviceOptionsEmpty() { + let options = HCIDeviceOptions(rawValue: 0) + for flag in HCIDeviceFlag.allCases { + XCTAssertFalse(options.contains(flag)) + } + XCTAssertTrue(options.flags.isEmpty) + } + + func testHCIDeviceOptionsFlagsPopulated() { + // Bit 2 -> .running; a non-zero bit position is representable in the set. + let options = HCIDeviceOptions(rawValue: 0b0000_0100) + XCTAssertFalse(options.flags.isEmpty) + XCTAssertTrue(options.flags.contains(.running)) + } +} From 8e90d3a24c6c2f75dfc735169ca076147098f103 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 22:43:07 -0400 Subject: [PATCH 14/29] Add ioctl tests for device-down, device-info, and RFCOMM --- .../BluetoothLinuxTests/IOControlTests.swift | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/Tests/BluetoothLinuxTests/IOControlTests.swift b/Tests/BluetoothLinuxTests/IOControlTests.swift index 2681757..36065d0 100644 --- a/Tests/BluetoothLinuxTests/IOControlTests.swift +++ b/Tests/BluetoothLinuxTests/IOControlTests.swift @@ -46,6 +46,88 @@ final class IOControlTests: XCTestCase { } } + func testDeviceDownIsTraced() throws { + try MockingDriver.withMockingEnabled { driver in + try fileDescriptor.deviceDown(for: .init(rawValue: 2)) + XCTAssertEqual( + driver.trace.dequeue(), + Trace.Entry(name: "ioctl", [ + fileDescriptor.rawValue, + HostControllerIO.deviceDown.rawValue, + Int32(2) + ]) + ) + XCTAssertTrue(driver.trace.isEmpty) + } + } + + func testDeviceInformationFakedKernelReply() throws { + try MockingDriver.withMockingEnabled { driver in + driver.ioctlHandler = { fd, request, pointer in + XCTAssertEqual(request, HostControllerIO.getDeviceInfo.rawValue) + guard let pointer else { + XCTFail("Expected an out-parameter for HCIGETDEVINFO") + errno = EINVAL + return -1 + } + var info = CInterop.HCIDeviceInformation(id: 0) + info.name = (0x68, 0x63, 0x69, 0x30, 0, 0, 0, 0) // "hci0" + info.address = (1, 2, 3, 4, 5, 6) + info.flags = 0b0000_0100 // .running bit + info.type = 0x13 // busType 0x3, controllerType 0x1 + pointer.storeBytes(of: info, as: CInterop.HCIDeviceInformation.self) + return 0 + } + let information = try fileDescriptor.deviceInformation(for: .init(rawValue: 0)) + XCTAssertEqual(information.id, HostController.ID(rawValue: 0)) + XCTAssertEqual(information.name, "hci0") + XCTAssertEqual(information.address, BluetoothAddress(bytes: (1, 2, 3, 4, 5, 6))) + XCTAssertTrue(information.flags.contains(.running)) + XCTAssertEqual( + driver.trace.dequeue(), + Trace.Entry(name: "ioctl", [ + fileDescriptor.rawValue, + HostControllerIO.getDeviceInfo.rawValue + ]) + ) + XCTAssertTrue(driver.trace.isEmpty) + } + } + + func testRFCOMMCreateDeviceIsTraced() throws { + try MockingDriver.withMockingEnabled { driver in + try fileDescriptor.rfcommCreateDevice( + id: .init(rawValue: 0), + flags: [.reuseDLC], + source: BluetoothAddress(bytes: (1, 2, 3, 4, 5, 6)), + destination: BluetoothAddress(bytes: (6, 5, 4, 3, 2, 1)), + channel: 1 + ) + XCTAssertEqual( + driver.trace.dequeue(), + Trace.Entry(name: "ioctl", [ + fileDescriptor.rawValue, + RFCOMMIO.createDevice.rawValue + ]) + ) + XCTAssertTrue(driver.trace.isEmpty) + } + } + + func testRFCOMMReleaseDeviceIsTraced() throws { + try MockingDriver.withMockingEnabled { driver in + try fileDescriptor.rfcommReleaseDevice(id: .init(rawValue: 3)) + XCTAssertEqual( + driver.trace.dequeue(), + Trace.Entry(name: "ioctl", [ + fileDescriptor.rawValue, + RFCOMMIO.releaseDevice.rawValue + ]) + ) + XCTAssertTrue(driver.trace.isEmpty) + } + } + func testDeviceListFakedKernelReply() throws { try MockingDriver.withMockingEnabled { driver in driver.ioctlHandler = { fd, request, pointer in From 782e3aec46ceca07bbec81eb338302eea535164e Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 22:23:08 -0400 Subject: [PATCH 15/29] Convert RFCOMMFlag to native OptionSet --- .../BluetoothLinux/RFCOMM/RFCOMMFlag.swift | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/Sources/BluetoothLinux/RFCOMM/RFCOMMFlag.swift b/Sources/BluetoothLinux/RFCOMM/RFCOMMFlag.swift index 8d405e0..eccfdd1 100644 --- a/Sources/BluetoothLinux/RFCOMM/RFCOMMFlag.swift +++ b/Sources/BluetoothLinux/RFCOMM/RFCOMMFlag.swift @@ -1,6 +1,6 @@ // // File.swift -// +// // // Created by Alsey Coleman Miller on 27/10/21. // @@ -9,10 +9,16 @@ import Bluetooth /// RFCOMM Flags @frozen -public enum RFCOMMFlag: UInt32, CaseIterable, BitMaskOption { - - case reuseDLC = 0x01 // RFCOMM_REUSE_DLC - case releaseOnHangup = 0x02 // RFCOMM_RELEASE_ONHUP - case hangupNow = 0x04 // RFCOMM_HANGUP_NOW - case serialAttached = 0x08 // RFCOMM_TTY_ATTACHED +public struct RFCOMMFlag: OptionSet, Equatable, Hashable, Sendable { + + public let rawValue: UInt32 + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + public static let reuseDLC = RFCOMMFlag(rawValue: 0x01) // RFCOMM_REUSE_DLC + public static let releaseOnHangup = RFCOMMFlag(rawValue: 0x02) // RFCOMM_RELEASE_ONHUP + public static let hangupNow = RFCOMMFlag(rawValue: 0x04) // RFCOMM_HANGUP_NOW + public static let serialAttached = RFCOMMFlag(rawValue: 0x08) // RFCOMM_TTY_ATTACHED } From 45c9fcc9437ef99f7934288992491209b6a3eb9b Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 22:23:08 -0400 Subject: [PATCH 16/29] Convert RFCOMMLinkMode to native OptionSet --- .../RFCOMM/RFCOMMLinkMode.swift | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/Sources/BluetoothLinux/RFCOMM/RFCOMMLinkMode.swift b/Sources/BluetoothLinux/RFCOMM/RFCOMMLinkMode.swift index e750232..10484a8 100644 --- a/Sources/BluetoothLinux/RFCOMM/RFCOMMLinkMode.swift +++ b/Sources/BluetoothLinux/RFCOMM/RFCOMMLinkMode.swift @@ -1,6 +1,6 @@ // // RFCOMMLinkMode.swift -// +// // // Created by Alsey Coleman Miller on 27/10/21. // @@ -9,12 +9,18 @@ import Bluetooth /// RFCOMM Link Mode @frozen -public enum RFCOMMLinkMode: UInt16, CaseIterable, BitMaskOption { - - case master = 0x0001 - case authenticated = 0x0002 - case encrypted = 0x0004 - case trusted = 0x0008 - case reliable = 0x0010 - case secure = 0x0020 +public struct RFCOMMLinkMode: OptionSet, Equatable, Hashable, Sendable { + + public let rawValue: UInt16 + + public init(rawValue: UInt16) { + self.rawValue = rawValue + } + + public static let master = RFCOMMLinkMode(rawValue: 0x0001) + public static let authenticated = RFCOMMLinkMode(rawValue: 0x0002) + public static let encrypted = RFCOMMLinkMode(rawValue: 0x0004) + public static let trusted = RFCOMMLinkMode(rawValue: 0x0008) + public static let reliable = RFCOMMLinkMode(rawValue: 0x0010) + public static let secure = RFCOMMLinkMode(rawValue: 0x0020) } From 11faf9f244172ef62d5c9cec9b31bd869c22c4cd Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 22:23:08 -0400 Subject: [PATCH 17/29] Convert HCIDeviceFlag to native OptionSet --- .../BluetoothLinux/HCI/HCIDeviceFlag.swift | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/Sources/BluetoothLinux/HCI/HCIDeviceFlag.swift b/Sources/BluetoothLinux/HCI/HCIDeviceFlag.swift index eebcc81..424985e 100644 --- a/Sources/BluetoothLinux/HCI/HCIDeviceFlag.swift +++ b/Sources/BluetoothLinux/HCI/HCIDeviceFlag.swift @@ -1,6 +1,6 @@ // // HCIDeviceFlag.swift -// +// // // Created by Alsey Coleman Miller on 16/10/21. // @@ -8,17 +8,33 @@ import Bluetooth /// HCI device flags -public enum HCIDeviceFlag: Int32, BitMaskOption, CaseIterable { - - case up - case initialized - case running - - case passiveScan - case interactiveScan - case authenticated - case encrypt - case inquiry - - case raw +public struct HCIDeviceFlag: OptionSet, Equatable, Hashable, Sendable { + + public let rawValue: UInt32 + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + public static let up = HCIDeviceFlag(rawValue: 1 << 0) + public static let initialized = HCIDeviceFlag(rawValue: 1 << 1) + public static let running = HCIDeviceFlag(rawValue: 1 << 2) + + public static let passiveScan = HCIDeviceFlag(rawValue: 1 << 3) + public static let interactiveScan = HCIDeviceFlag(rawValue: 1 << 4) + public static let authenticated = HCIDeviceFlag(rawValue: 1 << 5) + public static let encrypt = HCIDeviceFlag(rawValue: 1 << 6) + public static let inquiry = HCIDeviceFlag(rawValue: 1 << 7) + + public static let raw = HCIDeviceFlag(rawValue: 1 << 8) +} + +public extension HCIDeviceFlag { + + /// All defined HCI device flags. + static let all: HCIDeviceFlag = [ + .up, .initialized, .running, + .passiveScan, .interactiveScan, .authenticated, .encrypt, .inquiry, + .raw + ] } From a930a16492a87dc29c56b7f5d6e86390a80daaa3 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 22:23:08 -0400 Subject: [PATCH 18/29] Use OptionSet API for HCIDeviceOptions flags --- Sources/BluetoothLinux/HCI/HCIDeviceOptions.swift | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/Sources/BluetoothLinux/HCI/HCIDeviceOptions.swift b/Sources/BluetoothLinux/HCI/HCIDeviceOptions.swift index e531437..1fbd7ee 100644 --- a/Sources/BluetoothLinux/HCI/HCIDeviceOptions.swift +++ b/Sources/BluetoothLinux/HCI/HCIDeviceOptions.swift @@ -19,17 +19,11 @@ public struct HCIDeviceOptions: RawRepresentable, Equatable, Hashable { public extension HCIDeviceOptions { - var flags: BitMaskOptionSet { - var options = BitMaskOptionSet() - HCIDeviceFlag.allCases.forEach { - if contains($0) { - options.insert($0) - } - } - return options + var flags: HCIDeviceFlag { + HCIDeviceFlag(rawValue: rawValue).intersection(.all) } - + func contains(_ flag: HCIDeviceFlag) -> Bool { - return (self.rawValue + (UInt32(bitPattern: flag.rawValue) >> 5)) & (1 << (UInt32(bitPattern: flag.rawValue) & 31)) != 0 + HCIDeviceFlag(rawValue: rawValue).isSuperset(of: flag) } } From ba15a788a283d7db3ed8da81a8fb8a416a2552c1 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 22:23:08 -0400 Subject: [PATCH 19/29] Convert ScanOption to native OptionSet --- .../BluetoothLinux/HCI/IOCTL/HCIScan.swift | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/Sources/BluetoothLinux/HCI/IOCTL/HCIScan.swift b/Sources/BluetoothLinux/HCI/IOCTL/HCIScan.swift index 6e98a89..6cd4413 100644 --- a/Sources/BluetoothLinux/HCI/IOCTL/HCIScan.swift +++ b/Sources/BluetoothLinux/HCI/IOCTL/HCIScan.swift @@ -28,7 +28,7 @@ public extension HostController { func scan(duration: Int = 8, limit: Int = 255, deviceClass: (UInt8, UInt8, UInt8)? = nil, - options: BitMaskOptionSet = []) throws -> [InquiryResult] { + options: ScanOption = []) throws -> [InquiryResult] { assert(duration > 0, "Scan must be longer than 0 seconds") assert(limit > 0, "Must scan at least one device") @@ -66,12 +66,18 @@ public extension HostController { public extension HostController { /// Options for scanning Bluetooth devices - enum ScanOption: UInt16, BitMaskOption { - - /// The cache of previously detected devices is flushed before performing the current inquiry. - /// Otherwise, if flags is set to 0, then the results of previous inquiries may be returned, + struct ScanOption: OptionSet, Equatable, Hashable, Sendable { + + public let rawValue: UInt16 + + public init(rawValue: UInt16) { + self.rawValue = rawValue + } + + /// The cache of previously detected devices is flushed before performing the current inquiry. + /// Otherwise, if flags is set to 0, then the results of previous inquiries may be returned, /// even if the devices aren't in range anymore. - case flushCache = 0x0001 + public static let flushCache = ScanOption(rawValue: 0x0001) } struct InquiryResult { @@ -108,7 +114,7 @@ public extension HostControllerIO { public var deviceClass: (UInt8, UInt8, UInt8)? - public var options: BitMaskOptionSet + public var options: HostController.ScanOption public private(set) var response: [HostController.InquiryResult] @@ -117,7 +123,7 @@ public extension HostControllerIO { duration: UInt8 = 8, limit: UInt8 = 255, deviceClass: (UInt8, UInt8, UInt8)? = nil, - options: BitMaskOptionSet = [] + options: HostController.ScanOption = [] ) { self.device = device self.duration = duration @@ -183,7 +189,7 @@ internal extension SocketDescriptor { duration: UInt8 = 8, limit: UInt8 = 255, deviceClass: (UInt8, UInt8, UInt8)? = nil, - options: BitMaskOptionSet = [] + options: HostController.ScanOption = [] ) throws -> [HostController.InquiryResult] { var inquiry = HostControllerIO.Inquiry( device: id, From 9189a621f7e216620075e659e1839e0966a639d9 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 22:23:08 -0400 Subject: [PATCH 20/29] Adopt RFCOMMFlag OptionSet in RFCOMMDevice --- Sources/BluetoothLinux/RFCOMM/RFCOMMDevice.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/BluetoothLinux/RFCOMM/RFCOMMDevice.swift b/Sources/BluetoothLinux/RFCOMM/RFCOMMDevice.swift index 6cd6ea5..c91e14d 100644 --- a/Sources/BluetoothLinux/RFCOMM/RFCOMMDevice.swift +++ b/Sources/BluetoothLinux/RFCOMM/RFCOMMDevice.swift @@ -14,7 +14,7 @@ public struct RFCOMMDevice: Equatable, Hashable { public let id: HostController.ID - public var flags: BitMaskOptionSet + public var flags: RFCOMMFlag public var state: RFCOMMState From 926d5de1e419ee798da311a14a9079711c7e70d4 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 22:23:08 -0400 Subject: [PATCH 21/29] Adopt RFCOMMFlag OptionSet in RFCOMMCreateDevice --- .../BluetoothLinux/RFCOMM/IOCTL/RFCOMMCreateDevice.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/BluetoothLinux/RFCOMM/IOCTL/RFCOMMCreateDevice.swift b/Sources/BluetoothLinux/RFCOMM/IOCTL/RFCOMMCreateDevice.swift index a3408ff..28f1e6a 100644 --- a/Sources/BluetoothLinux/RFCOMM/IOCTL/RFCOMMCreateDevice.swift +++ b/Sources/BluetoothLinux/RFCOMM/IOCTL/RFCOMMCreateDevice.swift @@ -28,7 +28,7 @@ public extension RFCOMMIO { @_alwaysEmitIntoClient public init( id: HostController.ID, - flags: BitMaskOptionSet, + flags: RFCOMMFlag, source: BluetoothAddress, destination: BluetoothAddress, channel: UInt8 = 1 @@ -59,7 +59,7 @@ public extension RFCOMMIO.CreateDevice { } @_alwaysEmitIntoClient - var flags: BitMaskOptionSet { + var flags: RFCOMMFlag { return .init(rawValue: bytes.flags) } @@ -87,7 +87,7 @@ internal extension SocketDescriptor { @usableFromInline func rfcommCreateDevice( id: HostController.ID, - flags: BitMaskOptionSet = [], + flags: RFCOMMFlag = [], source: BluetoothAddress, destination: BluetoothAddress, channel: UInt8 = 1 From b7e03f9f2c2606b73a828228a8131bc4b92c2ab1 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 22:23:08 -0400 Subject: [PATCH 22/29] Adopt RFCOMMFlag OptionSet in RFCOMMReleaseDevice --- .../BluetoothLinux/RFCOMM/IOCTL/RFCOMMReleaseDevice.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/BluetoothLinux/RFCOMM/IOCTL/RFCOMMReleaseDevice.swift b/Sources/BluetoothLinux/RFCOMM/IOCTL/RFCOMMReleaseDevice.swift index 3639a9a..b4f4a5e 100644 --- a/Sources/BluetoothLinux/RFCOMM/IOCTL/RFCOMMReleaseDevice.swift +++ b/Sources/BluetoothLinux/RFCOMM/IOCTL/RFCOMMReleaseDevice.swift @@ -28,7 +28,7 @@ public extension RFCOMMIO { @_alwaysEmitIntoClient public init( id: HostController.ID, - flags: BitMaskOptionSet + flags: RFCOMMFlag ) { self.init(CInterop.RFCOMMDeviceRequest( device: id.rawValue, @@ -56,7 +56,7 @@ public extension RFCOMMIO.ReleaseDevice { } @_alwaysEmitIntoClient - var flags: BitMaskOptionSet { + var flags: RFCOMMFlag { return .init(rawValue: bytes.flags) } } @@ -68,7 +68,7 @@ internal extension SocketDescriptor { @usableFromInline func rfcommReleaseDevice( id: HostController.ID, - flags: BitMaskOptionSet = [] + flags: RFCOMMFlag = [] ) throws { var request = RFCOMMIO.ReleaseDevice( id: id, From de434e0f084191a85fd523c20439d00c3118a15b Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 22:23:08 -0400 Subject: [PATCH 23/29] Adopt RFCOMMLinkMode OptionSet in RFCOMMSocketOption --- Sources/BluetoothLinux/RFCOMM/RFCOMMSocketOption.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/BluetoothLinux/RFCOMM/RFCOMMSocketOption.swift b/Sources/BluetoothLinux/RFCOMM/RFCOMMSocketOption.swift index 7ce8098..9a007ce 100644 --- a/Sources/BluetoothLinux/RFCOMM/RFCOMMSocketOption.swift +++ b/Sources/BluetoothLinux/RFCOMM/RFCOMMSocketOption.swift @@ -64,9 +64,9 @@ public extension RFCOMMSocketOption { @_alwaysEmitIntoClient public static var id: RFCOMMSocketOption { .connectionInfo } - public var linkMode: BitMaskOptionSet + public var linkMode: RFCOMMLinkMode - public init(linkMode: BitMaskOptionSet = []) { + public init(linkMode: RFCOMMLinkMode = []) { self.linkMode = linkMode } From 631397f1c9e8f144f8e03ca91e9b8c1cd86682d2 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 22:58:53 -0400 Subject: [PATCH 24/29] Adapt value tests to OptionSet HCIDeviceFlag --- Tests/BluetoothLinuxTests/ValueTests.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Tests/BluetoothLinuxTests/ValueTests.swift b/Tests/BluetoothLinuxTests/ValueTests.swift index 2f124b4..2d7238f 100644 --- a/Tests/BluetoothLinuxTests/ValueTests.swift +++ b/Tests/BluetoothLinuxTests/ValueTests.swift @@ -99,16 +99,22 @@ final class ValueTests: XCTestCase { func testHCIDeviceOptionsEmpty() { let options = HCIDeviceOptions(rawValue: 0) - for flag in HCIDeviceFlag.allCases { + let allFlags: [HCIDeviceFlag] = [ + .up, .initialized, .running, + .passiveScan, .interactiveScan, .authenticated, .encrypt, .inquiry, + .raw + ] + for flag in allFlags { XCTAssertFalse(options.contains(flag)) } XCTAssertTrue(options.flags.isEmpty) } func testHCIDeviceOptionsFlagsPopulated() { - // Bit 2 -> .running; a non-zero bit position is representable in the set. - let options = HCIDeviceOptions(rawValue: 0b0000_0100) + // Bit 0 -> .up (now representable as a native OptionSet mask). + let options = HCIDeviceOptions(rawValue: 0b0000_0101) XCTAssertFalse(options.flags.isEmpty) + XCTAssertTrue(options.flags.contains(.up)) XCTAssertTrue(options.flags.contains(.running)) } } From da97f8aeffad3a4e7d16d5320a544c4156461c36 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 23:33:24 -0400 Subject: [PATCH 25/29] Fix RFCOMM device destination address mapping --- Sources/BluetoothLinux/RFCOMM/RFCOMMDevice.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/BluetoothLinux/RFCOMM/RFCOMMDevice.swift b/Sources/BluetoothLinux/RFCOMM/RFCOMMDevice.swift index c91e14d..ed1fe92 100644 --- a/Sources/BluetoothLinux/RFCOMM/RFCOMMDevice.swift +++ b/Sources/BluetoothLinux/RFCOMM/RFCOMMDevice.swift @@ -35,7 +35,7 @@ internal extension RFCOMMDevice { self.flags = .init(rawValue: cValue.flags) self.state = .init(rawValue: cValue.state) ?? .unknown self.source = cValue.source - self.destination = cValue.source + self.destination = cValue.destination self.channel = cValue.channel } } From 6a0f682ac19c6a85bb0130a211feb76a03a4fcd6 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 23:33:24 -0400 Subject: [PATCH 26/29] Fix inquiry result buffer layout to match kernel ABI --- .../BluetoothLinux/HCI/IOCTL/HCIScan.swift | 69 ++++++++++--------- 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/Sources/BluetoothLinux/HCI/IOCTL/HCIScan.swift b/Sources/BluetoothLinux/HCI/IOCTL/HCIScan.swift index 6cd4413..6ee6172 100644 --- a/Sources/BluetoothLinux/HCI/IOCTL/HCIScan.swift +++ b/Sources/BluetoothLinux/HCI/IOCTL/HCIScan.swift @@ -134,46 +134,49 @@ public extension HostControllerIO { } public mutating func withUnsafeMutablePointer(_ body: (UnsafeMutableRawPointer) throws -> (Result)) rethrows -> Result { - - let bufferSize = MemoryLayout.size - + (MemoryLayout.size * Int(limit)) - - let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + + // The kernel writes inquiry results at `sizeof(struct hci_inquiry_req)`, + // which is the stride (10), not the Swift size (9, tail padding excluded). + let headerSize = MemoryLayout.stride + let elementSize = MemoryLayout.stride + let bufferSize = headerSize + (elementSize * Int(limit)) + + let buffer = UnsafeMutableRawPointer.allocate( + byteCount: bufferSize, + alignment: MemoryLayout.alignment + ) defer { buffer.deallocate() } - - buffer.withMemoryRebound(to: CInterop.HCIInquiryRequest.self, capacity: 1) { - $0.pointee.id = self.device.rawValue - $0.pointee.lap = self.deviceClass ?? (0x33, 0x8b, 0x9e) - $0.pointee.flags = self.options.rawValue - $0.pointee.responseCount = self.limit - $0.pointee.length = self.duration - } - + buffer.initializeMemory(as: UInt8.self, repeating: 0, count: bufferSize) + + let request = buffer.bindMemory(to: CInterop.HCIInquiryRequest.self, capacity: 1) + request.pointee.id = self.device.rawValue + request.pointee.lap = self.deviceClass ?? (0x33, 0x8b, 0x9e) + request.pointee.flags = self.options.rawValue + request.pointee.responseCount = self.limit + request.pointee.length = self.duration + // call ioctl let result = try body(buffer) - - let resultCount = buffer.withMemoryRebound(to: CInterop.HCIInquiryRequest.self, capacity: 1) { - Int($0.pointee.responseCount) - } - + + let resultCount = Int(request.pointee.responseCount) + self.response.removeAll(keepingCapacity: true) self.response.reserveCapacity(resultCount) - + for index in 0 ..< resultCount { - let offset = MemoryLayout.size + (MemoryLayout.size * index) - buffer.advanced(by: offset).withMemoryRebound(to: CInterop.HCIInquiryResult.self, capacity: 1) { - let element = HostController.InquiryResult( - address: $0.pointee.address, - pscanRepMode: $0.pointee.pscanRepMode, - pscanPeriodMode: $0.pointee.pscanPeriodMode, - pscanMode: $0.pointee.pscanMode, - deviceClass: $0.pointee.deviceClass, - clockOffset: $0.pointee.clockOffset - ) - self.response.append(element) - } + let offset = headerSize + (elementSize * index) + let bytes = buffer.loadUnaligned(fromByteOffset: offset, as: CInterop.HCIInquiryResult.self) + let element = HostController.InquiryResult( + address: bytes.address, + pscanRepMode: bytes.pscanRepMode, + pscanPeriodMode: bytes.pscanPeriodMode, + pscanMode: bytes.pscanMode, + deviceClass: bytes.deviceClass, + clockOffset: bytes.clockOffset + ) + self.response.append(element) } - + return result } } From 3214ad14c2be5997f98e2f603d9619a55fb836d1 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 23:33:24 -0400 Subject: [PATCH 27/29] Fix RFCOMM device list buffer layout to match kernel ABI --- .../RFCOMM/IOCTL/RFCOMMGetDeviceList.swift | 49 ++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/Sources/BluetoothLinux/RFCOMM/IOCTL/RFCOMMGetDeviceList.swift b/Sources/BluetoothLinux/RFCOMM/IOCTL/RFCOMMGetDeviceList.swift index 1947db3..b2900d6 100644 --- a/Sources/BluetoothLinux/RFCOMM/IOCTL/RFCOMMGetDeviceList.swift +++ b/Sources/BluetoothLinux/RFCOMM/IOCTL/RFCOMMGetDeviceList.swift @@ -31,35 +31,40 @@ public extension RFCOMMIO { } public mutating func withUnsafeMutablePointer(_ body: (UnsafeMutableRawPointer) throws -> (Result)) rethrows -> Result { - - let bufferSize = MemoryLayout.size - + (MemoryLayout.size * self.limit) - - let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + + // The kernel's `struct rfcomm_dev_list_req` places the flexible + // `dev_info[]` array at the C struct size, i.e. the 2-byte count + // padded up to the element alignment (4), not the Swift size (2). + let elementAlignment = MemoryLayout.alignment + let headerSize = (MemoryLayout.size + elementAlignment - 1) + & ~(elementAlignment - 1) + let elementSize = MemoryLayout.stride + let bufferSize = headerSize + (elementSize * self.limit) + + let buffer = UnsafeMutableRawPointer.allocate( + byteCount: bufferSize, + alignment: elementAlignment + ) defer { buffer.deallocate() } - - buffer.withMemoryRebound(to: CInterop.RFCOMMDeviceListRequest.self, capacity: 1) { - $0.pointee.count = numericCast(self.limit) - } - + buffer.initializeMemory(as: UInt8.self, repeating: 0, count: bufferSize) + + let request = buffer.bindMemory(to: CInterop.RFCOMMDeviceListRequest.self, capacity: 1) + request.pointee.count = numericCast(self.limit) + // call ioctl let result = try body(buffer) - - let resultCount = buffer.withMemoryRebound(to: CInterop.RFCOMMDeviceListRequest.self, capacity: 1) { - Int($0.pointee.count) - } - + + let resultCount = Int(request.pointee.count) + self.response.removeAll(keepingCapacity: true) self.response.reserveCapacity(resultCount) - + for index in 0 ..< resultCount { - let offset = MemoryLayout.size + (MemoryLayout.size * index) - buffer.advanced(by: offset).withMemoryRebound(to: CInterop.RFCOMMDeviceInformation.self, capacity: 1) { - let element = RFCOMMDevice($0.pointee) - self.response.append(element) - } + let offset = headerSize + (elementSize * index) + let bytes = buffer.loadUnaligned(fromByteOffset: offset, as: CInterop.RFCOMMDeviceInformation.self) + self.response.append(RFCOMMDevice(bytes)) } - + return result } } From c42052a6ad9caebe8d0e6024a9140518f5471003 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 23:33:24 -0400 Subject: [PATCH 28/29] Add ioctl tests for inquiry and RFCOMM device queries --- .../BluetoothLinuxTests/IOControlTests.swift | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/Tests/BluetoothLinuxTests/IOControlTests.swift b/Tests/BluetoothLinuxTests/IOControlTests.swift index 36065d0..b2c4792 100644 --- a/Tests/BluetoothLinuxTests/IOControlTests.swift +++ b/Tests/BluetoothLinuxTests/IOControlTests.swift @@ -128,6 +128,125 @@ final class IOControlTests: XCTestCase { } } + func testInquiryFakedKernelReply() throws { + try MockingDriver.withMockingEnabled { driver in + driver.ioctlHandler = { fd, request, pointer in + XCTAssertEqual(request, HostControllerIO.inquiry.rawValue) + guard let pointer else { + XCTFail("Expected a request buffer for HCIINQUIRY") + errno = EINVAL + return -1 + } + // Verify the request the caller marshalled. + var inquiry = pointer.assumingMemoryBound(to: CInterop.HCIInquiryRequest.self).pointee + XCTAssertEqual(inquiry.id, 0) + XCTAssertEqual(inquiry.length, 4) + XCTAssertEqual(inquiry.responseCount, 8) + XCTAssertEqual(inquiry.flags, HostController.ScanOption.flushCache.rawValue) + XCTAssertEqual(inquiry.lap.0, 0x33) // GIAC + // Reply with 2 (zeroed) inquiry results, as the kernel would. + inquiry.responseCount = 2 + pointer.assumingMemoryBound(to: CInterop.HCIInquiryRequest.self).pointee = inquiry + for index in 0 ..< 2 { + // The kernel writes results at C sizeof (stride), see HCIScan.swift. + let offset = MemoryLayout.stride + + (MemoryLayout.stride * index) + pointer.storeBytes(of: CInterop.HCIInquiryResult(), toByteOffset: offset, as: CInterop.HCIInquiryResult.self) + } + return 0 + } + let results = try fileDescriptor.inquiry( + device: .init(rawValue: 0), + duration: 4, + limit: 8, + options: [.flushCache] + ) + XCTAssertEqual(results.count, 2) + XCTAssertEqual(results[0].address, .zero) + XCTAssertEqual( + driver.trace.dequeue(), + Trace.Entry(name: "ioctl", [ + fileDescriptor.rawValue, + HostControllerIO.inquiry.rawValue + ]) + ) + XCTAssertTrue(driver.trace.isEmpty) + } + } + + func testRFCOMMDeviceListFakedKernelReply() throws { + try MockingDriver.withMockingEnabled { driver in + let expected = CInterop.RFCOMMDeviceInformation( + id: 7, + flags: RFCOMMFlag.reuseDLC.rawValue, + state: RFCOMMState.connected.rawValue, + source: BluetoothAddress(bytes: (1, 2, 3, 4, 5, 6)), + destination: BluetoothAddress(bytes: (6, 5, 4, 3, 2, 1)), + channel: 2 + ) + driver.ioctlHandler = { fd, request, pointer in + XCTAssertEqual(request, RFCOMMIO.getDeviceList.rawValue) + guard let pointer else { + XCTFail("Expected a request buffer for RFCOMMGETDEVLIST") + errno = EINVAL + return -1 + } + pointer.assumingMemoryBound(to: CInterop.RFCOMMDeviceListRequest.self).pointee.count = 1 + // dev_info[] starts at the count padded to element alignment, + // matching `struct rfcomm_dev_list_req` (see RFCOMMGetDeviceList.swift). + let alignment = MemoryLayout.alignment + let offset = (MemoryLayout.size + alignment - 1) + & ~(alignment - 1) + pointer.storeBytes( + of: expected, + toByteOffset: offset, + as: CInterop.RFCOMMDeviceInformation.self + ) + return 0 + } + let devices = try fileDescriptor.rfcommListDevices() + XCTAssertEqual(devices.count, 1) + XCTAssertEqual(devices[0].id, HostController.ID(rawValue: 7)) + XCTAssertEqual(devices[0].flags, [.reuseDLC]) + XCTAssertEqual(devices[0].state, .connected) + XCTAssertEqual(devices[0].source, BluetoothAddress(bytes: (1, 2, 3, 4, 5, 6))) + XCTAssertEqual(devices[0].destination, BluetoothAddress(bytes: (6, 5, 4, 3, 2, 1))) + XCTAssertEqual(devices[0].channel, 2) + XCTAssertNotNil(driver.trace.dequeue()) + XCTAssertTrue(driver.trace.isEmpty) + } + } + + func testRFCOMMGetDeviceInformationFakedKernelReply() throws { + try MockingDriver.withMockingEnabled { driver in + driver.ioctlHandler = { fd, request, pointer in + XCTAssertEqual(request, RFCOMMIO.getDeviceInfo.rawValue) + guard let pointer else { + XCTFail("Expected an out-parameter for RFCOMMGETDEVINFO") + errno = EINVAL + return -1 + } + let info = pointer.assumingMemoryBound(to: CInterop.RFCOMMDeviceInformation.self) + XCTAssertEqual(info.pointee.id, 3) + info.pointee.flags = RFCOMMFlag.releaseOnHangup.rawValue + info.pointee.state = RFCOMMState.listening.rawValue + info.pointee.source = BluetoothAddress(bytes: (1, 2, 3, 4, 5, 6)) + info.pointee.destination = BluetoothAddress(bytes: (6, 5, 4, 3, 2, 1)) + info.pointee.channel = 5 + return 0 + } + let device = try fileDescriptor.rfcommGetDevice(id: .init(rawValue: 3)) + XCTAssertEqual(device.id, HostController.ID(rawValue: 3)) + XCTAssertEqual(device.flags, [.releaseOnHangup]) + XCTAssertEqual(device.state, .listening) + XCTAssertEqual(device.source, BluetoothAddress(bytes: (1, 2, 3, 4, 5, 6))) + XCTAssertEqual(device.destination, BluetoothAddress(bytes: (6, 5, 4, 3, 2, 1))) + XCTAssertEqual(device.channel, 5) + XCTAssertNotNil(driver.trace.dequeue()) + XCTAssertTrue(driver.trace.isEmpty) + } + } + func testDeviceListFakedKernelReply() throws { try MockingDriver.withMockingEnabled { driver in driver.ioctlHandler = { fd, request, pointer in From 2edd2d77a698ec868a90a360a591f278c4ddfdf3 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 17 Jul 2026 23:33:25 -0400 Subject: [PATCH 29/29] Add tests for socket options and remaining value types --- Tests/BluetoothLinuxTests/ValueTests.swift | 126 +++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/Tests/BluetoothLinuxTests/ValueTests.swift b/Tests/BluetoothLinuxTests/ValueTests.swift index 2d7238f..d3b9dc2 100644 --- a/Tests/BluetoothLinuxTests/ValueTests.swift +++ b/Tests/BluetoothLinuxTests/ValueTests.swift @@ -50,6 +50,22 @@ final class ValueTests: XCTestCase { XCTAssertEqual(Errno(HCIError.controllerBusy), .noFunction) } + func testErrnoFromHCIErrorRemainingBranches() { + XCTAssertEqual(Errno(HCIError.pageTimeout), .hostIsDown) + XCTAssertEqual(Errno(HCIError.authenticationFailure), .permissionDenied) + XCTAssertEqual(Errno(HCIError.keyMissing), .invalidArgument) + XCTAssertEqual(Errno(HCIError.maxConnections), .tooManyLinks) + XCTAssertEqual(Errno(HCIError.aclConnectionExists), .alreadyInProcess) + XCTAssertEqual(Errno(HCIError.rejectedLimitedResources), .connectionRefused) + XCTAssertEqual(Errno(HCIError.hostTimeout), .timedOut) + XCTAssertEqual(Errno(HCIError.unsupportedFeature), .notSupportedOnSocket) + XCTAssertEqual(Errno(HCIError.invalidParameters), .invalidArgument) + XCTAssertEqual(Errno(HCIError.remoteUserEndedConnection), .connectionReset) + XCTAssertEqual(Errno(HCIError.rejectedSecurity), .permissionDenied) + XCTAssertEqual(Errno(HCIError.scoOffsetRejected), .connectionRefused) + XCTAssertEqual(Errno(HCIError.unknownLMPPDU), .protocolError) + } + func testHostControllerIORawValueRoundTrip() { for value in HostControllerIO.allCases { XCTAssertEqual(HostControllerIO(rawValue: value.rawValue), value) @@ -117,4 +133,114 @@ final class ValueTests: XCTestCase { XCTAssertTrue(options.flags.contains(.up)) XCTAssertTrue(options.flags.contains(.running)) } + + func testLinkModeRawValues() { + XCTAssertEqual(LinkMode.accept.rawValue, 0x8000) + XCTAssertEqual(LinkMode.master.rawValue, 0x0001) + XCTAssertEqual(LinkMode.authenticated.rawValue, 0x0002) + XCTAssertEqual(LinkMode.encrypted.rawValue, 0x0004) + XCTAssertEqual(LinkMode.trusted.rawValue, 0x0008) + XCTAssertEqual(LinkMode.reliable.rawValue, 0x0010) + XCTAssertEqual(LinkMode.secure.rawValue, 0x0020) + } + + func testRFCOMMStateRoundTrip() { + for state in RFCOMMState.allCases { + XCTAssertEqual(RFCOMMState(rawValue: state.rawValue), state) + } + XCTAssertNil(RFCOMMState(rawValue: 0xFF)) + } + + func testHCIBusTypeDescriptions() { + let expected: [(HCIBusType, String)] = [ + (.virtual, "Virtual"), (.usb, "USB"), (.pcCard, "PCCARD"), + (.uart, "UART"), (.rs232, "RS232"), (.pci, "PCI"), + (.sdio, "SDIO"), (.spi, "SPI"), (.i2c, "I2C"), + (.smd, "SMD"), (.virtio, "VIRTIO") + ] + for (type, description) in expected { + XCTAssertEqual(type.description, description) + XCTAssertEqual(type.debugDescription, description) + } + XCTAssertEqual(HCIBusType(rawValue: 99).description, "Unknown 99") + } + + func testHCIControllerTypeDescriptions() { + XCTAssertEqual(HCIControllerType.primary.description, "Primary") + XCTAssertEqual(HCIControllerType.amp.description, "AMP") + XCTAssertEqual(HCIControllerType.amp.debugDescription, "AMP") + XCTAssertEqual(HCIControllerType(rawValue: 42).description, "Unknown 42") + } + + func testHCIPacketTypeRawValues() { + XCTAssertEqual(HCIPacketType.command.rawValue, 0x01) + XCTAssertEqual(HCIPacketType.acl.rawValue, 0x02) + XCTAssertEqual(HCIPacketType.sco.rawValue, 0x03) + XCTAssertEqual(HCIPacketType.event.rawValue, 0x04) + XCTAssertEqual(HCIPacketType.vendor.rawValue, 0xff) + } + + func testBluetoothSocketProtocol() { + XCTAssertEqual(BluetoothSocketProtocol.l2cap.rawValue, 0) + XCTAssertEqual(BluetoothSocketProtocol.hci.rawValue, 1) + XCTAssertEqual(BluetoothSocketProtocol.rfcomm.rawValue, 3) + XCTAssertEqual(BluetoothSocketProtocol.l2cap.type, .sequencedPacket) + XCTAssertEqual(BluetoothSocketProtocol.hci.type, .raw) + XCTAssertEqual(BluetoothSocketProtocol.sco.type, .sequencedPacket) + XCTAssertEqual(BluetoothSocketProtocol.rfcomm.type, .stream) + XCTAssertEqual(BluetoothSocketProtocol.bnep.type, .raw) + XCTAssertEqual(BluetoothSocketProtocol.cmtp.type, .raw) + XCTAssertEqual(BluetoothSocketProtocol.hidp.type, .raw) + XCTAssertEqual(BluetoothSocketProtocol.avdtp.type, .raw) + #if os(Linux) + XCTAssertEqual(BluetoothSocketProtocol.family, .bluetooth) + #endif + } + + func testSocketOptionIdentifiers() { + XCTAssertEqual(BluetoothSocketOption.security.rawValue, 4) + XCTAssertEqual(BluetoothSocketOption.receiveMTU.rawValue, 13) + XCTAssertEqual(RFCOMMSocketOption.connectionInfo.rawValue, 0x02) + XCTAssertEqual(RFCOMMSocketOption.linkMode.rawValue, 0x03) + XCTAssertEqual(RFCOMMSocketOption.optionLevel.rawValue, 18) + #if os(Linux) + XCTAssertEqual(BluetoothSocketOption.optionLevel, .bluetooth) + #endif + } + + func testSecuritySocketOptionRoundTrip() throws { + let option = BluetoothSocketOption.Security(level: .high, keySize: 16) + XCTAssertEqual(option.level, .high) + XCTAssertEqual(option.keySize, 16) + // Encode, then decode through the socket-option byte interface. + let decoded = BluetoothSocketOption.Security.withUnsafeBytes { destination in + option.withUnsafeBytes { source in + destination.copyMemory(from: source) + } + } + XCTAssertEqual(decoded, option) + XCTAssertEqual(decoded.level, .high) + XCTAssertEqual(decoded.keySize, 16) + XCTAssertEqual(BluetoothSocketOption.Security().level, .sdp) + } + + func testRFCOMMLinkModeSocketOptionRoundTrip() { + let option = RFCOMMSocketOption.LinkMode(linkMode: [.master, .encrypted]) + let decoded = RFCOMMSocketOption.LinkMode.withUnsafeBytes { destination in + option.withUnsafeBytes { source in + destination.copyMemory(from: source) + } + } + XCTAssertEqual(decoded.linkMode, [.master, .encrypted]) + XCTAssertTrue(RFCOMMSocketOption.LinkMode().linkMode.isEmpty) + } + + func testRFCOMMConnectionInfoRoundTrip() { + let empty = RFCOMMSocketOption.ConnectionInfo() + XCTAssertEqual(empty.handle, 0) + let decoded = RFCOMMSocketOption.ConnectionInfo.withUnsafeBytes { buffer in + buffer.storeBytes(of: 7, toByteOffset: 0, as: UInt16.self) + } + XCTAssertEqual(decoded.handle, 7) + } }