From eb74b97328db081444b4830b6300eb8f6eca6a65 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:23 -0400 Subject: [PATCH 01/22] Add executable targets for command line tools --- Package.swift | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/Package.swift b/Package.swift index 8bf0245..4e4b463 100644 --- a/Package.swift +++ b/Package.swift @@ -19,6 +19,26 @@ var package = Package( name: "BluetoothLinux", type: libraryType, targets: ["BluetoothLinux"] + ), + .executable( + name: "hcitool", + targets: ["hcitool"] + ), + .executable( + name: "hciconfig", + targets: ["hciconfig"] + ), + .executable( + name: "gatttool", + targets: ["gatttool"] + ), + .executable( + name: "gattserver", + targets: ["gattserver"] + ), + .executable( + name: "beacon", + targets: ["beacon"] ) ], dependencies: [ @@ -29,6 +49,10 @@ var package = Package( .package( url: "https://github.com/PureSwift/Socket.git", branch: "main" + ), + .package( + url: "https://github.com/apple/swift-argument-parser.git", + from: "1.5.0" ) ], targets: [ @@ -55,6 +79,72 @@ var package = Package( .define("ENABLE_MOCKING", .when(configuration: .debug)) ] ), + .executableTarget( + name: "hcitool", + dependencies: [ + "BluetoothLinux", + .product( + name: "BluetoothGAP", + package: "Bluetooth" + ), + .product( + name: "ArgumentParser", + package: "swift-argument-parser" + ) + ] + ), + .executableTarget( + name: "hciconfig", + dependencies: [ + "BluetoothLinux", + .product( + name: "ArgumentParser", + package: "swift-argument-parser" + ) + ] + ), + .executableTarget( + name: "gatttool", + dependencies: [ + "BluetoothLinux", + .product( + name: "BluetoothGATT", + package: "Bluetooth" + ), + .product( + name: "ArgumentParser", + package: "swift-argument-parser" + ) + ] + ), + .executableTarget( + name: "gattserver", + dependencies: [ + "BluetoothLinux", + .product( + name: "BluetoothGATT", + package: "Bluetooth" + ), + .product( + name: "BluetoothGAP", + package: "Bluetooth" + ), + .product( + name: "ArgumentParser", + package: "swift-argument-parser" + ) + ] + ), + .executableTarget( + name: "beacon", + dependencies: [ + "BluetoothLinux", + .product( + name: "ArgumentParser", + package: "swift-argument-parser" + ) + ] + ), .target( name: "CBluetoothLinux" ), From c9f150ad0279f8f3a645f0468a8be0a57012f078 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:23 -0400 Subject: [PATCH 02/22] Add hcitool executable --- Sources/hcitool/HCITool.swift | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 Sources/hcitool/HCITool.swift diff --git a/Sources/hcitool/HCITool.swift b/Sources/hcitool/HCITool.swift new file mode 100644 index 0000000..55c129e --- /dev/null +++ b/Sources/hcitool/HCITool.swift @@ -0,0 +1,27 @@ +// +// HCITool.swift +// BluetoothLinux +// +// Configure Bluetooth connections and query controller state. +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothHCI +import BluetoothLinux + +@main +struct HCITool: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "hcitool", + abstract: "Configure Bluetooth connections and query controller state.", + subcommands: [ + DeviceList.self, + Inquiry.self, + LowEnergyScan.self + ], + defaultSubcommand: DeviceList.self + ) +} From c6d6e938113aabe4648f0337b075322314da0b0e Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:23 -0400 Subject: [PATCH 03/22] Add controller argument parsing for hcitool --- Sources/hcitool/HostController+Command.swift | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 Sources/hcitool/HostController+Command.swift diff --git a/Sources/hcitool/HostController+Command.swift b/Sources/hcitool/HostController+Command.swift new file mode 100644 index 0000000..dd17950 --- /dev/null +++ b/Sources/hcitool/HostController+Command.swift @@ -0,0 +1,35 @@ +// +// HostController+Command.swift +// BluetoothLinux +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothLinux + +extension HostController.ID { + + /// Parse a controller identifier from a command line argument (e.g. `hci0` or `0`). + static func parse(_ argument: String) throws -> HostController.ID { + let string = argument.hasPrefix("hci") ? String(argument.dropFirst(3)) : argument + guard let rawValue = UInt16(string) else { + throw ValidationError("Invalid device identifier '\(argument)'") + } + return .init(rawValue: rawValue) + } +} + +extension HostController { + + /// Resolve the controller to use for a command. + static func command(device id: HostController.ID?) async throws -> HostController { + if let id { + return try await HostController(id: id) + } + guard let controller = await HostController.controllers.first else { + throw ValidationError("No Bluetooth controllers found.") + } + return controller + } +} From 003e80d6b75b9d13eb19b605c3765bc6f7c8f44e Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:23 -0400 Subject: [PATCH 04/22] Add hcitool dev command --- Sources/hcitool/DeviceList.swift | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 Sources/hcitool/DeviceList.swift diff --git a/Sources/hcitool/DeviceList.swift b/Sources/hcitool/DeviceList.swift new file mode 100644 index 0000000..d0ea59d --- /dev/null +++ b/Sources/hcitool/DeviceList.swift @@ -0,0 +1,28 @@ +// +// DeviceList.swift +// BluetoothLinux +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothLinux + +struct DeviceList: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "dev", + abstract: "List local Bluetooth controllers." + ) + + func run() async throws { + let controllers = await HostController.controllers + guard controllers.isEmpty == false else { + throw CleanExit.message("No Bluetooth controllers found.") + } + print("Devices:") + for controller in controllers { + print("\t\(controller.name)\t\(controller.address)") + } + } +} From d42eb7703ab8b0e72410179693598c21dad77fe6 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:23 -0400 Subject: [PATCH 05/22] Add hcitool inq command --- Sources/hcitool/Inquiry.swift | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 Sources/hcitool/Inquiry.swift diff --git a/Sources/hcitool/Inquiry.swift b/Sources/hcitool/Inquiry.swift new file mode 100644 index 0000000..4805402 --- /dev/null +++ b/Sources/hcitool/Inquiry.swift @@ -0,0 +1,42 @@ +// +// Inquiry.swift +// BluetoothLinux +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothLinux + +struct Inquiry: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "inq", + abstract: "Inquire remote devices (classic Bluetooth inquiry)." + ) + + @Option(name: [.customShort("i"), .long], help: "The controller to use (e.g. hci0).", transform: HostController.ID.parse) + var device: HostController.ID? + + @Option(help: "Inquiry duration (at most 1.28 * duration seconds).") + var duration: Int = 8 + + @Option(help: "Maximum amount of devices to scan.") + var limit: Int = 255 + + func run() async throws { + let controller = try await HostController.command(device: device) + print("Inquiring ...") + let results = try controller.scan(duration: duration, limit: limit) + for result in results { + let deviceClass = String( + format: "0x%02X%02X%02X", + result.deviceClass.2, + result.deviceClass.1, + result.deviceClass.0 + ) + let clockOffset = String(format: "0x%04X", result.clockOffset) + print("\t\(result.address)\tclass: \(deviceClass)\tclock offset: \(clockOffset)") + } + } +} From 0b7e57d4d6ea8a6b6202d1c8a1c8992a53241024 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:23 -0400 Subject: [PATCH 06/22] Add hcitool lescan command --- Sources/hcitool/LowEnergyScan.swift | 71 +++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 Sources/hcitool/LowEnergyScan.swift diff --git a/Sources/hcitool/LowEnergyScan.swift b/Sources/hcitool/LowEnergyScan.swift new file mode 100644 index 0000000..55ae11e --- /dev/null +++ b/Sources/hcitool/LowEnergyScan.swift @@ -0,0 +1,71 @@ +// +// LowEnergyScan.swift +// BluetoothLinux +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothGAP +import BluetoothHCI +import BluetoothLinux + +struct LowEnergyScan: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "lescan", + abstract: "Scan for Bluetooth Low Energy devices." + ) + + @Option(name: [.customShort("i"), .long], help: "The controller to use (e.g. hci0).", transform: HostController.ID.parse) + var device: HostController.ID? + + @Option(help: "Scan duration in seconds (scans until interrupted by default).") + var duration: UInt? + + @Flag(help: "Show duplicate advertising reports.") + var duplicates = false + + func run() async throws { + let controller = try await HostController.command(device: device) + print("LE Scan ...") + let stream = try await controller.lowEnergyScan(filterDuplicates: !duplicates) + let scanTask = Task { + for try await report in stream { + var line = "\(report.address) \(report.addressType == .random ? "(random)" : "(public)")" + if let name = report.responseData.localName { + line += " \(name)" + } + if let rssi = report.rssi { + line += " rssi: \(rssi.rawValue)" + } + print(line) + } + } + if let duration { + try await Task.sleep(nanoseconds: UInt64(duration) * 1_000_000_000) + stream.stop() + scanTask.cancel() + } else { + try await scanTask.value + } + } +} + +internal extension LowEnergyAdvertisingData { + + /// Decode the local name from GAP advertising data. + var localName: String? { + let decoder = GAPDataDecoder() + guard let decoded = try? decoder.decode(from: self) else { + return nil + } + if let name = decoded.compactMap({ $0 as? GAPCompleteLocalName }).first { + return name.name + } + if let name = decoded.compactMap({ $0 as? GAPShortLocalName }).first { + return name.name + } + return nil + } +} From 07787ae437bce64426f76d8b841a99b196c5a707 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:23 -0400 Subject: [PATCH 07/22] Add hciconfig executable --- Sources/hciconfig/HCIConfig.swift | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 Sources/hciconfig/HCIConfig.swift diff --git a/Sources/hciconfig/HCIConfig.swift b/Sources/hciconfig/HCIConfig.swift new file mode 100644 index 0000000..5fb338c --- /dev/null +++ b/Sources/hciconfig/HCIConfig.swift @@ -0,0 +1,38 @@ +// +// HCIConfig.swift +// BluetoothLinux +// +// Configure local Bluetooth controllers. +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothLinux + +@main +struct HCIConfig: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "hciconfig", + abstract: "Configure local Bluetooth controllers.", + subcommands: [ + List.self, + Up.self, + Down.self + ], + defaultSubcommand: List.self + ) +} + +extension HostController.ID { + + /// Parse a controller identifier from a command line argument (e.g. `hci0` or `0`). + static func parse(_ argument: String) throws -> HostController.ID { + let string = argument.hasPrefix("hci") ? String(argument.dropFirst(3)) : argument + guard let rawValue = UInt16(string) else { + throw ValidationError("Invalid device identifier '\(argument)'") + } + return .init(rawValue: rawValue) + } +} From 60292102460d4d5c26844c3b75d83761c697b30f Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:24 -0400 Subject: [PATCH 08/22] Add hciconfig list command --- Sources/hciconfig/List.swift | 60 ++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 Sources/hciconfig/List.swift diff --git a/Sources/hciconfig/List.swift b/Sources/hciconfig/List.swift new file mode 100644 index 0000000..aaf6f30 --- /dev/null +++ b/Sources/hciconfig/List.swift @@ -0,0 +1,60 @@ +// +// List.swift +// BluetoothLinux +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothLinux + +struct List: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "Print information for all local controllers." + ) + + @Argument(help: "The controller to print information for (e.g. hci0).", transform: HostController.ID.parse) + var device: HostController.ID? + + func run() async throws { + let identifiers: [HostController.ID] + if let device { + identifiers = [device] + } else { + identifiers = await HostController.controllers.map { $0.id } + guard identifiers.isEmpty == false else { + throw CleanExit.message("No Bluetooth controllers found.") + } + } + for (index, id) in identifiers.enumerated() { + let info = try HostController.deviceInformation(for: id) + if index > 0 { + print() + } + print("\(info.name):\tType: \(info.type) Bus: \(info.busType)") + print("\tBD Address: \(info.address)") + print("\t\(description(for: info.flags.flags))") + } + } + + private func description(for flags: HCIDeviceFlag) -> String { + let names: [(HCIDeviceFlag, String)] = [ + (.up, "UP"), + (.initialized, "INIT"), + (.running, "RUNNING"), + (.passiveScan, "PSCAN"), + (.interactiveScan, "ISCAN"), + (.authenticated, "AUTH"), + (.encrypt, "ENCRYPT"), + (.inquiry, "INQUIRY"), + (.raw, "RAW") + ] + var components = names.compactMap { flags.contains($0.0) ? $0.1 : nil } + if flags.contains(.up) == false { + components.insert("DOWN", at: 0) + } + return components.joined(separator: " ") + } +} From ecf3da1af0e9eae89e3bc6f699d79dcaafe468a7 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:24 -0400 Subject: [PATCH 09/22] Add hciconfig up command --- Sources/hciconfig/Up.swift | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 Sources/hciconfig/Up.swift diff --git a/Sources/hciconfig/Up.swift b/Sources/hciconfig/Up.swift new file mode 100644 index 0000000..6129a5c --- /dev/null +++ b/Sources/hciconfig/Up.swift @@ -0,0 +1,24 @@ +// +// Up.swift +// BluetoothLinux +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothLinux + +struct Up: ParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "up", + abstract: "Open and initialize a controller (requires root)." + ) + + @Argument(help: "The controller to enable (e.g. hci0).", transform: HostController.ID.parse) + var device: HostController.ID + + func run() throws { + try HostController.enable(device: device) + } +} From 27c280c3e60cb36e58bd0c76f36076d1e300a196 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:24 -0400 Subject: [PATCH 10/22] Add hciconfig down command --- Sources/hciconfig/Down.swift | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 Sources/hciconfig/Down.swift diff --git a/Sources/hciconfig/Down.swift b/Sources/hciconfig/Down.swift new file mode 100644 index 0000000..373703b --- /dev/null +++ b/Sources/hciconfig/Down.swift @@ -0,0 +1,24 @@ +// +// Down.swift +// BluetoothLinux +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothLinux + +struct Down: ParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "down", + abstract: "Close a controller (requires root)." + ) + + @Argument(help: "The controller to disable (e.g. hci0).", transform: HostController.ID.parse) + var device: HostController.ID + + func run() throws { + try HostController.disable(device: device) + } +} From 4ffef34ae866f3e7c737106eb967229165c942e8 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:24 -0400 Subject: [PATCH 11/22] Add gatttool executable --- Sources/gatttool/GATTTool.swift | 210 ++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 Sources/gatttool/GATTTool.swift diff --git a/Sources/gatttool/GATTTool.swift b/Sources/gatttool/GATTTool.swift new file mode 100644 index 0000000..3c63b7e --- /dev/null +++ b/Sources/gatttool/GATTTool.swift @@ -0,0 +1,210 @@ +// +// GATTTool.swift +// BluetoothLinux +// +// GATT client for Bluetooth Low Energy peripherals. +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothGATT +import BluetoothHCI +import BluetoothLinux + +@main +struct GATTTool: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "gatttool", + abstract: "GATT client for Bluetooth Low Energy peripherals.", + subcommands: [ + Primary.self, + Characteristics.self, + Read.self, + Write.self, + Notify.self + ] + ) +} + +/// Connection options shared by all subcommands. +struct ConnectionOptions: ParsableArguments { + + @Option(name: [.customShort("b"), .long], help: "The Bluetooth address of the remote device.", transform: { argument in + guard let address = BluetoothAddress(rawValue: argument) else { + throw ValidationError("Invalid Bluetooth address '\(argument)'") + } + return address + }) + var destination: BluetoothAddress + + @Flag(name: [.customShort("r"), .long], help: "The remote device uses a random address.") + var random = false + + @Option(name: [.customShort("i"), .long], help: "The controller to use (e.g. hci0).", transform: HostController.ID.parse) + var device: HostController.ID? + + @Flag(name: .shortAndLong, help: "Log GATT client events.") + var verbose = false +} + +/// A connected GATT client with its IO pump task. +struct GATTToolSession { + + typealias Client = GATTClient + + let client: Client + + let pump: Task + + static func connect(_ options: ConnectionOptions) async throws -> GATTToolSession { + let controller = try await HostController.command(device: options.device) + let localAddress = try await controller.readDeviceAddress() + let connection = try BluetoothLinux.L2CAPSocket.Connection.lowEnergyClient( + address: localAddress, + destination: options.destination, + isRandom: options.random + ) + if options.verbose { + print("Connected to \(options.destination)") + } + var log: (@Sendable (String) -> ())? = nil + if options.verbose { + log = { print("[GATT] \($0)") } + } + let client = await GATTClient( + socket: connection, + log: log + ) + let pump = Task { + while Task.isCancelled == false { + try await Task.sleep(nanoseconds: 100_000) + try await client.run() + } + } + return GATTToolSession(client: client, pump: pump) + } + + func close() { + pump.cancel() + } + + /// Discover all primary services and their characteristics. + func discoverAll() async throws -> [(service: Client.Service, characteristics: [Client.Characteristic])] { + var result: [(service: Client.Service, characteristics: [Client.Characteristic])] = [] + let services = try await client.discoverAllPrimaryServices() + for service in services { + let characteristics = try await client.discoverAllCharacteristics(of: service) + result.append((service, characteristics)) + } + return result + } + + /// Find a characteristic by its value handle. + func characteristic(handle: UInt16) async throws -> (service: (declaration: Client.Service, characteristics: [Client.Characteristic]), characteristic: Client.Characteristic) { + for (service, characteristics) in try await discoverAll() { + if let characteristic = characteristics.first(where: { $0.handle.value == handle }) { + return ((service, characteristics), characteristic) + } + } + throw ValidationError("No characteristic with value handle \(handle.hexadecimal)") + } + + /// Find characteristics by UUID. + func characteristics(uuid: BluetoothUUID) async throws -> [(service: (declaration: Client.Service, characteristics: [Client.Characteristic]), characteristic: Client.Characteristic)] { + var result: [(service: (declaration: Client.Service, characteristics: [Client.Characteristic]), characteristic: Client.Characteristic)] = [] + for (service, characteristics) in try await discoverAll() { + for characteristic in characteristics where characteristic.uuid == uuid { + result.append(((service, characteristics), characteristic)) + } + } + return result + } +} + +// MARK: - Argument Parsing + +extension HostController.ID { + + /// Parse a controller identifier from a command line argument (e.g. `hci0` or `0`). + static func parse(_ argument: String) throws -> HostController.ID { + let string = argument.hasPrefix("hci") ? String(argument.dropFirst(3)) : argument + guard let rawValue = UInt16(string) else { + throw ValidationError("Invalid device identifier '\(argument)'") + } + return .init(rawValue: rawValue) + } +} + +extension HostController { + + /// Resolve the controller to use for a command. + static func command(device id: HostController.ID?) async throws -> HostController { + if let id { + return try await HostController(id: id) + } + guard let controller = await HostController.controllers.first else { + throw ValidationError("No Bluetooth controllers found.") + } + return controller + } +} + +/// Parse an attribute handle from a command line argument (e.g. `0x0021` or `33`). +func parseHandle(_ argument: String) throws -> UInt16 { + let handle: UInt16? + if argument.hasPrefix("0x") || argument.hasPrefix("0X") { + handle = UInt16(argument.dropFirst(2), radix: 16) + } else { + handle = UInt16(argument) + } + guard let handle else { + throw ValidationError("Invalid handle '\(argument)'") + } + return handle +} + +/// Parse a Bluetooth UUID from a command line argument (e.g. `180A` or a 128-bit UUID string). +func parseUUID(_ argument: String) throws -> BluetoothUUID { + guard let uuid = BluetoothUUID(rawValue: argument) else { + throw ValidationError("Invalid UUID '\(argument)'") + } + return uuid +} + +/// Parse hexadecimal bytes from a command line argument (e.g. `0x0100` or `AABBCC`). +func parseHexData(_ argument: String) throws -> Data { + var string = argument + if string.hasPrefix("0x") || string.hasPrefix("0X") { + string = String(string.dropFirst(2)) + } + guard string.count % 2 == 0, string.isEmpty == false else { + throw ValidationError("Invalid hexadecimal value '\(argument)'") + } + var data = Data(capacity: string.count / 2) + var index = string.startIndex + while index < string.endIndex { + let next = string.index(index, offsetBy: 2) + guard let byte = UInt8(string[index ..< next], radix: 16) else { + throw ValidationError("Invalid hexadecimal value '\(argument)'") + } + data.append(byte) + index = next + } + return data +} + +extension UInt16 { + + var hexadecimal: String { + String(format: "0x%04X", self) + } +} + +extension Data { + + var hexadecimal: String { + map { String(format: "%02x", $0) }.joined(separator: " ") + } +} From ffd7c2cf3fb26437331e946e183040330916b09d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:24 -0400 Subject: [PATCH 12/22] Add gatttool primary command --- Sources/gatttool/Primary.swift | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 Sources/gatttool/Primary.swift diff --git a/Sources/gatttool/Primary.swift b/Sources/gatttool/Primary.swift new file mode 100644 index 0000000..64680b7 --- /dev/null +++ b/Sources/gatttool/Primary.swift @@ -0,0 +1,30 @@ +// +// Primary.swift +// BluetoothLinux +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothGATT +import BluetoothLinux + +struct Primary: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "primary", + abstract: "Discover all primary services." + ) + + @OptionGroup + var options: ConnectionOptions + + func run() async throws { + let session = try await GATTToolSession.connect(options) + defer { session.close() } + let services = try await session.client.discoverAllPrimaryServices() + for service in services { + print("attr handle \(service.handle.hexadecimal), end grp handle \(service.end.hexadecimal) uuid: \(service.uuid)") + } + } +} From 76d8071963030226b511782a92bb1bd205e12b88 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:24 -0400 Subject: [PATCH 13/22] Add gatttool characteristics command --- Sources/gatttool/Characteristics.swift | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 Sources/gatttool/Characteristics.swift diff --git a/Sources/gatttool/Characteristics.swift b/Sources/gatttool/Characteristics.swift new file mode 100644 index 0000000..100ed27 --- /dev/null +++ b/Sources/gatttool/Characteristics.swift @@ -0,0 +1,32 @@ +// +// Characteristics.swift +// BluetoothLinux +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothGATT +import BluetoothLinux + +struct Characteristics: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "characteristics", + abstract: "Discover all characteristics." + ) + + @OptionGroup + var options: ConnectionOptions + + func run() async throws { + let session = try await GATTToolSession.connect(options) + defer { session.close() } + for (service, characteristics) in try await session.discoverAll() { + print("service: \(service.uuid) (\(service.handle.hexadecimal) - \(service.end.hexadecimal))") + for characteristic in characteristics { + print("\thandle \(characteristic.handle.declaration.hexadecimal), char properties \(String(format: "0x%02X", characteristic.properties.rawValue)), char value handle \(characteristic.handle.value.hexadecimal), uuid \(characteristic.uuid)") + } + } + } +} From 49bae72a9032b595ac2748f496f16affd65c0703 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:24 -0400 Subject: [PATCH 14/22] Add gatttool read command --- Sources/gatttool/Read.swift | 52 +++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 Sources/gatttool/Read.swift diff --git a/Sources/gatttool/Read.swift b/Sources/gatttool/Read.swift new file mode 100644 index 0000000..c640fb2 --- /dev/null +++ b/Sources/gatttool/Read.swift @@ -0,0 +1,52 @@ +// +// Read.swift +// BluetoothLinux +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothGATT +import BluetoothLinux + +struct Read: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "read", + abstract: "Read a characteristic value." + ) + + @OptionGroup + var options: ConnectionOptions + + @Option(name: [.customShort("a"), .long], help: "The value handle of the characteristic (e.g. 0x0021).", transform: parseHandle) + var handle: UInt16? + + @Option(name: [.customShort("u"), .long], help: "The UUID of the characteristic (e.g. 2A00).", transform: parseUUID) + var uuid: BluetoothUUID? + + func validate() throws { + guard (handle != nil) != (uuid != nil) else { + throw ValidationError("Specify either --handle or --uuid.") + } + } + + func run() async throws { + let session = try await GATTToolSession.connect(options) + defer { session.close() } + if let handle { + let (_, characteristic) = try await session.characteristic(handle: handle) + let value = try await session.client.readCharacteristic(characteristic) + print("Characteristic value/descriptor: \(value.hexadecimal)") + } else if let uuid { + let matches = try await session.characteristics(uuid: uuid) + guard matches.isEmpty == false else { + throw ValidationError("No characteristic with UUID \(uuid)") + } + for (_, characteristic) in matches { + let value = try await session.client.readCharacteristic(characteristic) + print("handle: \(characteristic.handle.value.hexadecimal) \t value: \(value.hexadecimal)") + } + } + } +} From eed8de355da489af2c7336ade3f409e000b9126c Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:24 -0400 Subject: [PATCH 15/22] Add gatttool write command --- Sources/gatttool/Write.swift | 42 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 Sources/gatttool/Write.swift diff --git a/Sources/gatttool/Write.swift b/Sources/gatttool/Write.swift new file mode 100644 index 0000000..4093754 --- /dev/null +++ b/Sources/gatttool/Write.swift @@ -0,0 +1,42 @@ +// +// Write.swift +// BluetoothLinux +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothGATT +import BluetoothLinux + +struct Write: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "write", + abstract: "Write a characteristic value." + ) + + @OptionGroup + var options: ConnectionOptions + + @Option(name: [.customShort("a"), .long], help: "The value handle of the characteristic (e.g. 0x0021).", transform: parseHandle) + var handle: UInt16 + + @Argument(help: "The value to write, in hexadecimal (e.g. 0x0100).", transform: parseHexData) + var value: Data + + @Flag(help: "Write without response (ATT Write Command).") + var noResponse = false + + func run() async throws { + let session = try await GATTToolSession.connect(options) + defer { session.close() } + let (_, characteristic) = try await session.characteristic(handle: handle) + try await session.client.writeCharacteristic( + characteristic, + data: value, + withResponse: !noResponse + ) + print("Characteristic value was written successfully") + } +} From b274a89593952b13d326ae101e07724ce700a5f7 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:24 -0400 Subject: [PATCH 16/22] Add gatttool notify command --- Sources/gatttool/Notify.swift | 59 +++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 Sources/gatttool/Notify.swift diff --git a/Sources/gatttool/Notify.swift b/Sources/gatttool/Notify.swift new file mode 100644 index 0000000..b33f022 --- /dev/null +++ b/Sources/gatttool/Notify.swift @@ -0,0 +1,59 @@ +// +// Notify.swift +// BluetoothLinux +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothGATT +import BluetoothLinux + +struct Notify: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "notify", + abstract: "Subscribe to characteristic notifications and listen until interrupted." + ) + + @OptionGroup + var options: ConnectionOptions + + @Option(name: [.customShort("a"), .long], help: "The value handle of the characteristic (e.g. 0x0021).", transform: parseHandle) + var handle: UInt16 + + func run() async throws { + let session = try await GATTToolSession.connect(options) + defer { session.close() } + let (service, characteristic) = try await session.characteristic(handle: handle) + let descriptors = try await session.client.discoverDescriptors( + of: characteristic, + service: service + ) + let valueHandle = characteristic.handle.value + var notification: GATTToolSession.Client.Notification? = nil + if characteristic.properties.contains(.notify) { + notification = { data in + print("Notification handle = \(valueHandle.hexadecimal) value: \(data.hexadecimal)") + } + } + var indication: GATTToolSession.Client.Notification? = nil + if characteristic.properties.contains(.indicate) { + indication = { data in + print("Indication handle = \(valueHandle.hexadecimal) value: \(data.hexadecimal)") + } + } + guard notification != nil || indication != nil else { + throw ValidationError("Characteristic \(characteristic.uuid) does not support notifications or indications.") + } + try await session.client.clientCharacteristicConfiguration( + characteristic, + notification: notification, + indication: indication, + descriptors: descriptors + ) + print("Listening for notifications (Ctrl-C to exit) ...") + // keep the connection alive until interrupted + try await session.pump.value + } +} From 4a07c2b8e293804a5d4274a5573d59587dd5f797 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:24 -0400 Subject: [PATCH 17/22] Add gattserver executable --- Sources/gattserver/GATTServerTool.swift | 154 ++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 Sources/gattserver/GATTServerTool.swift diff --git a/Sources/gattserver/GATTServerTool.swift b/Sources/gattserver/GATTServerTool.swift new file mode 100644 index 0000000..bcee34c --- /dev/null +++ b/Sources/gattserver/GATTServerTool.swift @@ -0,0 +1,154 @@ +// +// GATTServerTool.swift +// BluetoothLinux +// +// Advertise and serve a demo GATT database. +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothGAP +import BluetoothGATT +import BluetoothHCI +import BluetoothLinux + +@main +struct GATTServerTool: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "gattserver", + abstract: "Advertise and serve a demo GATT database (requires root)." + ) + + @Option(name: [.customShort("i"), .long], help: "The controller to use (e.g. hci0).", transform: HostController.ID.parse) + var device: HostController.ID? + + @Option(help: "The advertised local name.") + var name: String = "BluetoothLinux" + + @Flag(name: .shortAndLong, help: "Log GATT server events.") + var verbose = false + + func run() async throws { + let controller = try await HostController.command(device: device) + try await startAdvertising(controller) + let address = try await controller.readDeviceAddress() + let serverSocket = try L2CAPSocket.Server.lowEnergyServer(address: address) + print("Listening on \(address) ...") + while true { + // wait for a connection + while serverSocket.status.accept == false { + if let error = serverSocket.status.error { + throw error + } + try await Task.sleep(nanoseconds: 100_000) + } + let connection = try serverSocket.accept() + print("Connected") + let server = GATTServer( + socket: connection, + maximumTransmissionUnit: .max, + maximumPreparedWrites: 1000, + database: Self.database, + log: verbose ? { print("[GATT] \($0)") } : nil + ) + do { + while true { + try server.run() + try await Task.sleep(nanoseconds: 100_000) + } + } catch { + print("Disconnected: \(error)") + } + } + } + + private func startAdvertising(_ controller: HostController) async throws { + do { + try await controller.enableLowEnergyAdvertising(false) + } catch HCIError.commandDisallowed { + // already disabled + } + let advertisingData = GAPDataEncoder.encode( + GAPShortLocalName(name: name) + ) + try await controller.setLowEnergyAdvertisingData(advertisingData) + do { + try await controller.enableLowEnergyAdvertising() + } catch HCIError.commandDisallowed { + // already enabled + } + } + + /// Demo GATT database (Device Information + Battery services). + static var database: GATTDatabase { + let deviceInformation = GATTAttribute.Service( + uuid: .bit16(0x180A), // Device Information + isPrimary: true, + characteristics: [ + GATTAttribute.Characteristic( + uuid: GATTManufacturerNameString.uuid, + value: Data(GATTManufacturerNameString(rawValue: "PureSwift")), + permissions: [.read], + properties: [.read], + descriptors: [] + ), + GATTAttribute.Characteristic( + uuid: GATTModelNumber.uuid, + value: Data(GATTModelNumber(rawValue: "BluetoothLinux")), + permissions: [.read], + properties: [.read], + descriptors: [] + ), + GATTAttribute.Characteristic( + uuid: GATTSoftwareRevisionString.uuid, + value: Data(GATTSoftwareRevisionString(rawValue: "1.0.0")), + permissions: [.read], + properties: [.read], + descriptors: [] + ) + ] + ) + let battery = GATTAttribute.Service( + uuid: GATTBatteryService.uuid, + isPrimary: true, + characteristics: [ + GATTAttribute.Characteristic( + uuid: GATTBatteryLevel.uuid, + value: Data(GATTBatteryLevel(level: .init(rawValue: 100)!)), + permissions: [.read], + properties: [.read], + descriptors: [] + ) + ] + ) + return GATTDatabase(services: [deviceInformation, battery]) + } +} + +extension HostController.ID { + + /// Parse a controller identifier from a command line argument (e.g. `hci0` or `0`). + static func parse(_ argument: String) throws -> HostController.ID { + let string = argument.hasPrefix("hci") ? String(argument.dropFirst(3)) : argument + guard let rawValue = UInt16(string) else { + throw ValidationError("Invalid device identifier '\(argument)'") + } + return .init(rawValue: rawValue) + } +} + +extension HostController { + + /// Resolve the controller to use for a command. + static func command(device id: HostController.ID?) async throws -> HostController { + if let id { + return try await HostController(id: id) + } + guard let controller = await HostController.controllers.first else { + throw ValidationError("No Bluetooth controllers found.") + } + return controller + } +} From db197b64af0099310a879091fb31c709cb37b4de Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:24 -0400 Subject: [PATCH 18/22] Add beacon executable --- Sources/beacon/Beacon.swift | 51 +++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 Sources/beacon/Beacon.swift diff --git a/Sources/beacon/Beacon.swift b/Sources/beacon/Beacon.swift new file mode 100644 index 0000000..1cf7b55 --- /dev/null +++ b/Sources/beacon/Beacon.swift @@ -0,0 +1,51 @@ +// +// Beacon.swift +// BluetoothLinux +// +// Broadcast Bluetooth Low Energy beacons. +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothHCI +import BluetoothLinux + +@main +struct Beacon: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "beacon", + abstract: "Broadcast Bluetooth Low Energy beacons.", + subcommands: [ + IBeacon.self, + Stop.self + ] + ) +} + +extension HostController.ID { + + /// Parse a controller identifier from a command line argument (e.g. `hci0` or `0`). + static func parse(_ argument: String) throws -> HostController.ID { + let string = argument.hasPrefix("hci") ? String(argument.dropFirst(3)) : argument + guard let rawValue = UInt16(string) else { + throw ValidationError("Invalid device identifier '\(argument)'") + } + return .init(rawValue: rawValue) + } +} + +extension HostController { + + /// Resolve the controller to use for a command. + static func command(device id: HostController.ID?) async throws -> HostController { + if let id { + return try await HostController(id: id) + } + guard let controller = await HostController.controllers.first else { + throw ValidationError("No Bluetooth controllers found.") + } + return controller + } +} From b17f14b13d88651e63acb269a7d63d221bfbcfee Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:24 -0400 Subject: [PATCH 19/22] Add beacon ibeacon command --- Sources/beacon/IBeacon.swift | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 Sources/beacon/IBeacon.swift diff --git a/Sources/beacon/IBeacon.swift b/Sources/beacon/IBeacon.swift new file mode 100644 index 0000000..9471e97 --- /dev/null +++ b/Sources/beacon/IBeacon.swift @@ -0,0 +1,50 @@ +// +// IBeacon.swift +// BluetoothLinux +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothHCI +import BluetoothLinux + +struct IBeacon: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "ibeacon", + abstract: "Advertise as an Apple iBeacon (requires root)." + ) + + @Option(name: [.customShort("i"), .long], help: "The controller to use (e.g. hci0).", transform: HostController.ID.parse) + var device: HostController.ID? + + @Argument(help: "The proximity UUID of the beacon.", transform: { argument in + guard let uuid = UUID(uuidString: argument) else { + throw ValidationError("Invalid UUID '\(argument)'") + } + return uuid + }) + var uuid: UUID + + @Option(help: "The value identifying a group of beacons.") + var major: UInt16 = 0 + + @Option(help: "The value identifying a specific beacon within a group.") + var minor: UInt16 = 0 + + @Option(help: "The measured signal strength (in dBm) at 1 meter.") + var rssi: Int8 = -59 + + func run() async throws { + let controller = try await HostController.command(device: device) + let beacon = AppleBeacon( + uuid: uuid, + major: major, + minor: minor, + rssi: rssi + ) + try await controller.iBeacon(beacon) + print("Advertising iBeacon \(uuid) (major: \(major), minor: \(minor)) on \(controller.name)") + } +} From 28b074c5f8bf481f4cf880a08e9f8d8d248dceec Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:24 -0400 Subject: [PATCH 20/22] Add beacon stop command --- Sources/beacon/Stop.swift | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 Sources/beacon/Stop.swift diff --git a/Sources/beacon/Stop.swift b/Sources/beacon/Stop.swift new file mode 100644 index 0000000..2f4cbbf --- /dev/null +++ b/Sources/beacon/Stop.swift @@ -0,0 +1,31 @@ +// +// Stop.swift +// BluetoothLinux +// + +import Foundation +import ArgumentParser +import Bluetooth +import BluetoothHCI +import BluetoothLinux + +struct Stop: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "stop", + abstract: "Stop advertising (requires root)." + ) + + @Option(name: [.customShort("i"), .long], help: "The controller to use (e.g. hci0).", transform: HostController.ID.parse) + var device: HostController.ID? + + func run() async throws { + let controller = try await HostController.command(device: device) + do { + try await controller.enableLowEnergyAdvertising(false) + } catch HCIError.commandDisallowed { + // already disabled + } + print("Stopped advertising on \(controller.name)") + } +} From 1e24ebd611f2f3b5f387532e1d09fbae42e22fe8 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:38:24 -0400 Subject: [PATCH 21/22] Add command line tools documentation --- TOOLS.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 TOOLS.md diff --git a/TOOLS.md b/TOOLS.md new file mode 100644 index 0000000..f22849e --- /dev/null +++ b/TOOLS.md @@ -0,0 +1,62 @@ +# Command Line Tools + +This package includes command line tools for interacting with Bluetooth controllers on Linux, +built on `BluetoothLinux` and the [Bluetooth](https://github.com/PureSwift/Bluetooth) protocol stack. +Most commands require root privileges. + +## hcitool + +Configure Bluetooth connections and query controller state. + +- `hcitool dev` — list local Bluetooth controllers (default). +- `hcitool inq` — inquire remote devices (classic Bluetooth inquiry). +- `hcitool lescan` — scan for Bluetooth Low Energy devices, decoding the advertised local name. + +## hciconfig + +Configure local Bluetooth controllers. + +- `hciconfig list` — print information for all local controllers (default). +- `hciconfig up hci0` — open and initialize a controller. +- `hciconfig down hci0` — close a controller. + +## gatttool + +GATT client for Bluetooth Low Energy peripherals. All subcommands take +`-b
` for the remote device, `-r` if it uses a random address, +`-i hci0` to select a controller, and `-v` for verbose logging. + +- `gatttool primary` — discover all primary services. +- `gatttool characteristics` — discover all characteristics. +- `gatttool read -a 0x0021` / `gatttool read -u 2A00` — read a characteristic value by handle or UUID. +- `gatttool write -a 0x0021 0x0100` — write a characteristic value (`--no-response` for write without response). +- `gatttool notify -a 0x0021` — subscribe to notifications and listen until interrupted. + +## gattserver + +Advertise and serve a demo GATT database (Device Information and Battery services). + +- `gattserver --name MyDevice` — advertise with the given local name and accept connections. + +## beacon + +Broadcast Bluetooth Low Energy beacons. + +- `beacon ibeacon --major 1 --minor 2 --rssi -59` — advertise as an iBeacon. +- `beacon stop` — stop advertising. + +## Roadmap + +Planned functionality, in rough priority order: + +- Connection info commands (`hcitool con`, `rssi`, `lq`) — requires Swift wrappers for the + connection list, connection info, and authentication info ioctls (identifiers already defined + in `HostControllerIO`). +- Additional `hciconfig` commands (auth, encrypt, packet type, link policy, block list) over the + remaining defined ioctls. +- Remote name request for classic devices. +- L2CAP echo ping — requires a raw (`SOCK_RAW`) L2CAP socket. +- Management API socket (`HCI_CHANNEL_CONTROL`) for modern adapter control + (power, pairing, discoverable, bonding). +- Monitor channel and btsnoop capture for packet logging. +- ATT over BR/EDR (PSM 31) and Enhanced ATT (PSM 0x27). From 39cb8edff1764c44b16fcc54e73e29bbbabcd86f Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 18 Jul 2026 00:40:31 -0400 Subject: [PATCH 22/22] Remove command line tools documentation --- TOOLS.md | 62 -------------------------------------------------------- 1 file changed, 62 deletions(-) delete mode 100644 TOOLS.md diff --git a/TOOLS.md b/TOOLS.md deleted file mode 100644 index f22849e..0000000 --- a/TOOLS.md +++ /dev/null @@ -1,62 +0,0 @@ -# Command Line Tools - -This package includes command line tools for interacting with Bluetooth controllers on Linux, -built on `BluetoothLinux` and the [Bluetooth](https://github.com/PureSwift/Bluetooth) protocol stack. -Most commands require root privileges. - -## hcitool - -Configure Bluetooth connections and query controller state. - -- `hcitool dev` — list local Bluetooth controllers (default). -- `hcitool inq` — inquire remote devices (classic Bluetooth inquiry). -- `hcitool lescan` — scan for Bluetooth Low Energy devices, decoding the advertised local name. - -## hciconfig - -Configure local Bluetooth controllers. - -- `hciconfig list` — print information for all local controllers (default). -- `hciconfig up hci0` — open and initialize a controller. -- `hciconfig down hci0` — close a controller. - -## gatttool - -GATT client for Bluetooth Low Energy peripherals. All subcommands take -`-b
` for the remote device, `-r` if it uses a random address, -`-i hci0` to select a controller, and `-v` for verbose logging. - -- `gatttool primary` — discover all primary services. -- `gatttool characteristics` — discover all characteristics. -- `gatttool read -a 0x0021` / `gatttool read -u 2A00` — read a characteristic value by handle or UUID. -- `gatttool write -a 0x0021 0x0100` — write a characteristic value (`--no-response` for write without response). -- `gatttool notify -a 0x0021` — subscribe to notifications and listen until interrupted. - -## gattserver - -Advertise and serve a demo GATT database (Device Information and Battery services). - -- `gattserver --name MyDevice` — advertise with the given local name and accept connections. - -## beacon - -Broadcast Bluetooth Low Energy beacons. - -- `beacon ibeacon --major 1 --minor 2 --rssi -59` — advertise as an iBeacon. -- `beacon stop` — stop advertising. - -## Roadmap - -Planned functionality, in rough priority order: - -- Connection info commands (`hcitool con`, `rssi`, `lq`) — requires Swift wrappers for the - connection list, connection info, and authentication info ioctls (identifiers already defined - in `HostControllerIO`). -- Additional `hciconfig` commands (auth, encrypt, packet type, link policy, block list) over the - remaining defined ioctls. -- Remote name request for classic devices. -- L2CAP echo ping — requires a raw (`SOCK_RAW`) L2CAP socket. -- Management API socket (`HCI_CHANNEL_CONTROL`) for modern adapter control - (power, pairing, discoverable, bonding). -- Monitor channel and btsnoop capture for packet logging. -- ATT over BR/EDR (PSM 31) and Enhanced ATT (PSM 0x27).