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
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
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))
+ ]
)
]
)
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
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)
)
}
}
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
)
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
+ ]
}
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)
}
}
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)
}
diff --git a/Sources/BluetoothLinux/HCI/IOCTL/HCIScan.swift b/Sources/BluetoothLinux/HCI/IOCTL/HCIScan.swift
index 6e98a89..6ee6172 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
@@ -128,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
}
}
@@ -183,7 +192,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,
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()
+ }
+}
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
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
+}
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
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
}
}
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,
diff --git a/Sources/BluetoothLinux/RFCOMM/RFCOMMDevice.swift b/Sources/BluetoothLinux/RFCOMM/RFCOMMDevice.swift
index 6cd6ea5..ed1fe92 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
@@ -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
}
}
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
}
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)
}
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
}
diff --git a/Tests/BluetoothLinuxTests/IOControlTests.swift b/Tests/BluetoothLinuxTests/IOControlTests.swift
new file mode 100644
index 0000000..b2c4792
--- /dev/null
+++ b/Tests/BluetoothLinuxTests/IOControlTests.swift
@@ -0,0 +1,329 @@
+//
+// 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 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 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
+ 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
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: []
diff --git a/Tests/BluetoothLinuxTests/ValueTests.swift b/Tests/BluetoothLinuxTests/ValueTests.swift
new file mode 100644
index 0000000..d3b9dc2
--- /dev/null
+++ b/Tests/BluetoothLinuxTests/ValueTests.swift
@@ -0,0 +1,246 @@
+//
+// 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 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)
+ }
+ }
+
+ 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)
+ 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 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))
+ }
+
+ 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)
+ }
+}