From 49e8263cd1cbe30cd9fb77eabd8e7b0028daa3d4 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 2 Aug 2026 20:59:41 -0400 Subject: [PATCH 01/11] Add big-endian integer read/write helpers for SDP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDP PDUs are network byte order, unlike most Bluetooth wire formats in this package, which are little-endian — so this module reads/writes multi-byte integers explicitly rather than reusing the little-endian-oriented DataConvertible conformances elsewhere. --- Sources/BluetoothSDP/SDPBigEndian.swift | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 Sources/BluetoothSDP/SDPBigEndian.swift diff --git a/Sources/BluetoothSDP/SDPBigEndian.swift b/Sources/BluetoothSDP/SDPBigEndian.swift new file mode 100644 index 000000000..99abec376 --- /dev/null +++ b/Sources/BluetoothSDP/SDPBigEndian.swift @@ -0,0 +1,31 @@ +// +// SDPBigEndian.swift +// Bluetooth +// +// SDP PDUs are big-endian (network byte order), unlike most Bluetooth +// wire formats in this package, which are little-endian. Rather than +// reuse the little-endian-oriented `DataConvertible` conformances +// elsewhere in this module (`UInt16.init(bytes:)` and friends encode +// raw native/little-endian memory), every multi-byte integer in this +// module is read and written explicitly, most-significant-byte-first, +// through these two small generic helpers. +// + +internal extension FixedWidthInteger { + + /// Parses `self` from `bytes`, most-significant byte first. + init(bigEndianBytes bytes: Bytes) where Bytes.Element == UInt8 { + self = bytes.reduce(into: 0 as Self) { result, byte in + result = (result << 8) | Self(truncatingIfNeeded: byte) + } + } + + /// The big-endian (network byte order) bytes of `self`, most + /// significant byte first. + var bigEndianBytes: [UInt8] { + let byteCount = Self.bitWidth / 8 + return (0 ..< byteCount).reversed().map { shift in + UInt8(truncatingIfNeeded: self >> (shift * 8)) + } + } +} From e8930ff81251a1b6d44f84b6deb8cc2826bb79cd Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 2 Aug 2026 20:59:50 -0400 Subject: [PATCH 02/11] Add SDPDataElement, the pure-Swift SDP data model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every value inside an SDP PDU or service record — attribute values, search patterns, attribute ID lists — is a data element, self- describing via a one-byte type/size-descriptor header. Conforms to this package's DataConvertible, matching how the rest of the Bluetooth package (BluetoothGAP, BluetoothGATT) encodes its own wire formats, rather than a C ABI. --- Sources/BluetoothSDP/SDPDataElement.swift | 342 ++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 Sources/BluetoothSDP/SDPDataElement.swift diff --git a/Sources/BluetoothSDP/SDPDataElement.swift b/Sources/BluetoothSDP/SDPDataElement.swift new file mode 100644 index 000000000..81a8019c4 --- /dev/null +++ b/Sources/BluetoothSDP/SDPDataElement.swift @@ -0,0 +1,342 @@ +// +// SDPDataElement.swift +// Bluetooth +// +// The SDP data element: every value inside an SDP PDU or service +// record — attribute values, search patterns, attribute ID lists — +// is one of these, self-describing via a one-byte type/size-descriptor +// header (SDP Protocol, Bluetooth Core Spec Vol 3, Part B, §3.1). +// + +import Bluetooth + +/// An SDP (Service Discovery Protocol) data element. +/// +/// Every element starts with a header byte: the upper 5 bits are the +/// type, the lower 3 bits are a size descriptor — either the value's +/// width directly (fixed-width types) or which of a 1/2/4-byte length +/// field follows (variable-width types). +@frozen +public enum SDPDataElement: Hashable, Sendable { + + /// The nil value — no associated data. + case null + + case unsignedInt8(UInt8) + case unsignedInt16(UInt16) + case unsignedInt32(UInt32) + case unsignedInt64(UInt64) + case unsignedInt128(UInt128) + + case signedInt8(Int8) + case signedInt16(Int16) + case signedInt32(Int32) + case signedInt64(Int64) + + /// A 128-bit signed integer, stored as its raw two's-complement bit + /// pattern — SDP's own reference implementation has no dedicated + /// 128-bit signed integer storage either, and reuses the unsigned + /// 128-bit representation for it. + case signedInt128(UInt128) + + case uuid(BluetoothUUID) + + /// A UTF-8 text string. + case text(String) + + case boolean(Bool) + + /// An ordered sequence of data elements — SDP's equivalent of an + /// array or tuple; used for attribute values (service class ID + /// lists, protocol descriptor lists) and for search patterns. + case sequence([SDPDataElement]) + + /// A set of alternative representations of the same value, of + /// which a consumer picks one. Rare in practice; included for + /// completeness. + case alternative([SDPDataElement]) + + case url(String) +} + +// MARK: - Type / Size Descriptor + +internal extension SDPDataElement { + + /// The upper 5 bits of the header byte. + enum ElementType: UInt8 { + case null = 0 + case unsignedInteger = 1 + case signedInteger = 2 + case uuid = 3 + case text = 4 + case boolean = 5 + case sequence = 6 + case alternative = 7 + case url = 8 + } + + /// Smallest size descriptor (5, 6, or 7) whose 1/2/4-byte explicit + /// length field can hold `byteCount`. + static func lengthSizeDescriptor(for byteCount: Int) -> UInt8 { + if byteCount <= Int(UInt8.max) { return 5 } + if byteCount <= Int(UInt16.max) { return 6 } + return 7 + } + + static func header(_ type: ElementType, _ sizeDescriptor: UInt8) -> UInt8 { + (type.rawValue << 3) | sizeDescriptor + } +} + +// MARK: - DataConvertible + +extension SDPDataElement: DataConvertible { + + public init?(data: Data) { + var consumed = 0 + guard let element = SDPDataElement(parsing: Array(data), consumed: &consumed), + consumed == data.count + else { return nil } + self = element + } + + public func append(to data: inout Data) { + data += encodedBytes + } + + /// The number of bytes encoding this element produces, without + /// actually encoding — used to size a containing sequence's length + /// field without an O(n²) double-encode. + public var dataLength: Int { + switch self { + case .null: + return 1 + case .unsignedInt8, .signedInt8, .boolean: + return 2 + case .unsignedInt16, .signedInt16: + return 3 + case .unsignedInt32, .signedInt32: + return 5 + case .unsignedInt64, .signedInt64: + return 9 + case .unsignedInt128, .signedInt128: + return 17 + case let .uuid(value): + switch value { + case .bit16: return 3 + case .bit32: return 5 + case .bit128: return 17 + } + case let .text(value): + return Self.variableLengthTotal(Array(value.utf8).count) + case let .url(value): + return Self.variableLengthTotal(Array(value.utf8).count) + case let .sequence(elements): + return Self.variableLengthTotal(elements.reduce(0) { $0 + $1.dataLength }) + case let .alternative(elements): + return Self.variableLengthTotal(elements.reduce(0) { $0 + $1.dataLength }) + } + } +} + +// MARK: - Encoding + +private extension SDPDataElement { + + /// This element encoded as SDP wire bytes. + var encodedBytes: [UInt8] { + switch self { + case .null: + return [Self.header(.null, 0)] + case let .unsignedInt8(value): + return [Self.header(.unsignedInteger, 0), value] + case let .unsignedInt16(value): + return [Self.header(.unsignedInteger, 1)] + value.bigEndianBytes + case let .unsignedInt32(value): + return [Self.header(.unsignedInteger, 2)] + value.bigEndianBytes + case let .unsignedInt64(value): + return [Self.header(.unsignedInteger, 3)] + value.bigEndianBytes + case let .unsignedInt128(value): + return [Self.header(.unsignedInteger, 4)] + value.bigEndianBytes + case let .signedInt8(value): + return [Self.header(.signedInteger, 0), UInt8(bitPattern: value)] + case let .signedInt16(value): + return [Self.header(.signedInteger, 1)] + UInt16(bitPattern: value).bigEndianBytes + case let .signedInt32(value): + return [Self.header(.signedInteger, 2)] + UInt32(bitPattern: value).bigEndianBytes + case let .signedInt64(value): + return [Self.header(.signedInteger, 3)] + UInt64(bitPattern: value).bigEndianBytes + case let .signedInt128(value): + return [Self.header(.signedInteger, 4)] + value.bigEndianBytes + case let .uuid(value): + return Self.encodeUUID(value) + case let .text(value): + return Self.encodeVariableLength(.text, Array(value.utf8)) + case let .boolean(value): + return [Self.header(.boolean, 0), value ? 1 : 0] + case let .sequence(elements): + return Self.encodeVariableLength(.sequence, elements.flatMap(\.encodedBytes)) + case let .alternative(elements): + return Self.encodeVariableLength(.alternative, elements.flatMap(\.encodedBytes)) + case let .url(value): + return Self.encodeVariableLength(.url, Array(value.utf8)) + } + } + + static func encodeUUID(_ uuid: BluetoothUUID) -> [UInt8] { + switch uuid { + case let .bit16(value): + return [header(.uuid, 1)] + value.bigEndianBytes + case let .bit32(value): + return [header(.uuid, 2)] + value.bigEndianBytes + case let .bit128(value): + return [header(.uuid, 4)] + value.bigEndianBytes + } + } + + static func encodeVariableLength(_ type: ElementType, _ bytes: [UInt8]) -> [UInt8] { + let sizeDescriptor = lengthSizeDescriptor(for: bytes.count) + var result = [header(type, sizeDescriptor)] + switch sizeDescriptor { + case 5: result.append(UInt8(bytes.count)) + case 6: result.append(contentsOf: UInt16(bytes.count).bigEndianBytes) + default: result.append(contentsOf: UInt32(bytes.count).bigEndianBytes) + } + result.append(contentsOf: bytes) + return result + } + + static func variableLengthTotal(_ byteCount: Int) -> Int { + let lengthFieldSize: Int + switch lengthSizeDescriptor(for: byteCount) { + case 5: lengthFieldSize = 1 + case 6: lengthFieldSize = 2 + default: lengthFieldSize = 4 + } + return 1 + lengthFieldSize + byteCount + } +} + +// MARK: - Decoding + +internal extension SDPDataElement { + + /// Parses a single data element from the start of `bytes`, which + /// may contain trailing data after it (as it does when parsing one + /// element out of a sequence). `consumed` is set to the number of + /// bytes the element occupied. + init?(parsing bytes: some Collection, consumed: inout Int) { + let bytes = Array(bytes) + guard let first = bytes.first, + let type = ElementType(rawValue: first >> 3) + else { return nil } + let sizeDescriptor = first & 0x07 + + func fixedWidthValue(_ width: Int) -> [UInt8]? { + guard bytes.count >= 1 + width else { return nil } + return Array(bytes[1 ..< 1 + width]) + } + + switch type { + case .null: + self = .null + consumed = 1 + + case .unsignedInteger, .signedInteger: + let width: Int + switch sizeDescriptor { + case 0: width = 1 + case 1: width = 2 + case 2: width = 4 + case 3: width = 8 + case 4: width = 16 + default: return nil + } + guard let valueBytes = fixedWidthValue(width) else { return nil } + consumed = 1 + width + if type == .unsignedInteger { + switch width { + case 1: self = .unsignedInt8(valueBytes[0]) + case 2: self = .unsignedInt16(UInt16(bigEndianBytes: valueBytes)) + case 4: self = .unsignedInt32(UInt32(bigEndianBytes: valueBytes)) + case 8: self = .unsignedInt64(UInt64(bigEndianBytes: valueBytes)) + default: self = .unsignedInt128(UInt128(bigEndianBytes: valueBytes)) + } + } else { + switch width { + case 1: self = .signedInt8(Int8(bitPattern: valueBytes[0])) + case 2: self = .signedInt16(Int16(bitPattern: UInt16(bigEndianBytes: valueBytes))) + case 4: self = .signedInt32(Int32(bitPattern: UInt32(bigEndianBytes: valueBytes))) + case 8: self = .signedInt64(Int64(bitPattern: UInt64(bigEndianBytes: valueBytes))) + default: self = .signedInt128(UInt128(bigEndianBytes: valueBytes)) + } + } + + case .uuid: + let width: Int + switch sizeDescriptor { + case 1: width = 2 + case 2: width = 4 + case 4: width = 16 + default: return nil + } + guard let valueBytes = fixedWidthValue(width) else { return nil } + consumed = 1 + width + switch width { + case 2: self = .uuid(.bit16(UInt16(bigEndianBytes: valueBytes))) + case 4: self = .uuid(.bit32(UInt32(bigEndianBytes: valueBytes))) + default: self = .uuid(.bit128(UInt128(bigEndianBytes: valueBytes))) + } + + case .boolean: + guard let valueBytes = fixedWidthValue(1) else { return nil } + consumed = 2 + self = .boolean(valueBytes[0] != 0) + + case .text, .url, .sequence, .alternative: + guard let (length, headerSize) = Self.variableLength(sizeDescriptor, bytes) else { return nil } + let valueStart = 1 + headerSize + guard bytes.count >= valueStart + length else { return nil } + let valueBytes = Array(bytes[valueStart ..< valueStart + length]) + consumed = valueStart + length + + switch type { + case .text: + self = .text(String(decoding: valueBytes, as: UTF8.self)) + case .url: + self = .url(String(decoding: valueBytes, as: UTF8.self)) + case .sequence, .alternative: + var elements: [SDPDataElement] = [] + var offset = 0 + while offset < valueBytes.count { + var elementConsumed = 0 + guard let element = SDPDataElement(parsing: valueBytes[offset...], consumed: &elementConsumed), + elementConsumed > 0 + else { return nil } + elements.append(element) + offset += elementConsumed + } + self = type == .sequence ? .sequence(elements) : .alternative(elements) + default: + return nil + } + } + } + + private static func variableLength(_ sizeDescriptor: UInt8, _ bytes: [UInt8]) -> (length: Int, headerSize: Int)? { + switch sizeDescriptor { + case 5: + guard bytes.count >= 2 else { return nil } + return (Int(bytes[1]), 1) + case 6: + guard bytes.count >= 3 else { return nil } + return (Int(UInt16(bigEndianBytes: bytes[1 ..< 3])), 2) + case 7: + guard bytes.count >= 5 else { return nil } + return (Int(UInt32(bigEndianBytes: bytes[1 ..< 5])), 4) + default: + return nil + } + } +} From b753bce1ceb4229e6b48d9e19614de28d31f688b Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 2 Aug 2026 20:59:56 -0400 Subject: [PATCH 03/11] Add SDPAttributeID, the universal service record attribute IDs --- Sources/BluetoothSDP/SDPAttributeID.swift | 100 ++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 Sources/BluetoothSDP/SDPAttributeID.swift diff --git a/Sources/BluetoothSDP/SDPAttributeID.swift b/Sources/BluetoothSDP/SDPAttributeID.swift new file mode 100644 index 000000000..7d047b43e --- /dev/null +++ b/Sources/BluetoothSDP/SDPAttributeID.swift @@ -0,0 +1,100 @@ +// +// SDPAttributeID.swift +// Bluetooth +// +// The universal attribute IDs every SDP service record shares (SDP +// Protocol, Bluetooth Core Spec Vol 3, Part B, §5.1), plus the +// per-record language-base offset scheme the string attributes +// (name/description/provider) are defined relative to. +// + +/// An SDP service record attribute ID. +public struct SDPAttributeID: RawRepresentable, Hashable, Sendable, Codable { + + public let rawValue: UInt16 + + public init(rawValue: UInt16) { + self.rawValue = rawValue + } +} + +public extension SDPAttributeID { + + /// A 32-bit number that uniquely identifies each service record. + static var serviceRecordHandle: SDPAttributeID { SDPAttributeID(rawValue: 0x0000) } + + /// A list of service class UUIDs, most specific first. + static var serviceClassIDList: SDPAttributeID { SDPAttributeID(rawValue: 0x0001) } + + static var serviceRecordState: SDPAttributeID { SDPAttributeID(rawValue: 0x0002) } + + /// A UUID identifying this particular service instance. + static var serviceID: SDPAttributeID { SDPAttributeID(rawValue: 0x0003) } + + /// One entry per protocol in the protocol stack used to gain + /// access to the service, outermost protocol first — the entry + /// after the outermost typically carries the L2CAP PSM or RFCOMM + /// channel number the service listens on. + static var protocolDescriptorList: SDPAttributeID { SDPAttributeID(rawValue: 0x0004) } + + static var browseGroupList: SDPAttributeID { SDPAttributeID(rawValue: 0x0005) } + + /// A sequence of (language, encoding, attribute ID base) triples; + /// the base offset into which the string attributes below are + /// added to get the actual attribute ID used for a given language. + static var languageBaseAttributeIDList: SDPAttributeID { SDPAttributeID(rawValue: 0x0006) } + + static var serviceInfoTimeToLive: SDPAttributeID { SDPAttributeID(rawValue: 0x0007) } + + static var serviceAvailability: SDPAttributeID { SDPAttributeID(rawValue: 0x0008) } + + /// One entry per Bluetooth profile the service conforms to: a + /// (profile UUID, version) pair. + static var bluetoothProfileDescriptorList: SDPAttributeID { SDPAttributeID(rawValue: 0x0009) } + + static var documentationURL: SDPAttributeID { SDPAttributeID(rawValue: 0x000A) } + + static var clientExecutableURL: SDPAttributeID { SDPAttributeID(rawValue: 0x000B) } + + static var iconURL: SDPAttributeID { SDPAttributeID(rawValue: 0x000C) } + + static var additionalProtocolDescriptorLists: SDPAttributeID { SDPAttributeID(rawValue: 0x000D) } +} + +public extension SDPAttributeID { + + /// The offsets added to a language base (from + /// ``languageBaseAttributeIDList``) to get the actual attribute ID + /// of a language-specific string attribute. + enum PrimaryLanguageOffset: UInt16, Sendable { + case serviceName = 0x0000 + case serviceDescription = 0x0001 + case providerName = 0x0002 + } + + /// The default primary-language base offset (0x0100), used when a + /// record doesn't have its own ``languageBaseAttributeIDList``. + static var primaryLanguageBase: UInt16 { 0x0100 } + + /// The attribute ID for `offset` under `base` (the primary + /// language's base, from ``languageBaseAttributeIDList``, or + /// ``primaryLanguageBase`` for the default language). + static func string(_ offset: PrimaryLanguageOffset, base: UInt16 = primaryLanguageBase) -> SDPAttributeID { + SDPAttributeID(rawValue: base + offset.rawValue) + } +} + +extension SDPAttributeID: ExpressibleByIntegerLiteral { + + public init(integerLiteral value: UInt16) { + self.init(rawValue: value) + } +} + +extension SDPAttributeID: CustomStringConvertible { + + public var description: String { + let hex = String(rawValue, radix: 16, uppercase: true) + return "0x" + String(repeating: "0", count: max(0, 4 - hex.count)) + hex + } +} From 6e1ff5e28584a0b1fc5d29294717c508bbc51f7f Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 2 Aug 2026 20:59:56 -0400 Subject: [PATCH 04/11] Add SDPError, the SDP ErrorResponse error codes --- Sources/BluetoothSDP/SDPError.swift | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 Sources/BluetoothSDP/SDPError.swift diff --git a/Sources/BluetoothSDP/SDPError.swift b/Sources/BluetoothSDP/SDPError.swift new file mode 100644 index 000000000..6ed8a70b9 --- /dev/null +++ b/Sources/BluetoothSDP/SDPError.swift @@ -0,0 +1,42 @@ +// +// SDPError.swift +// Bluetooth +// +// The error codes an SDP server returns in an ErrorResponse PDU (SDP +// Protocol, Bluetooth Core Spec Vol 3, Part B, §4.4.1). +// + +/// An SDP `ErrorResponse` error code. +public struct SDPError: RawRepresentable, Hashable, Sendable, Error, Codable { + + public let rawValue: UInt16 + + public init(rawValue: UInt16) { + self.rawValue = rawValue + } +} + +public extension SDPError { + + static var invalidVersion: SDPError { SDPError(rawValue: 0x0001) } + static var invalidServiceRecordHandle: SDPError { SDPError(rawValue: 0x0002) } + static var invalidRequestSyntax: SDPError { SDPError(rawValue: 0x0003) } + static var invalidPDUSize: SDPError { SDPError(rawValue: 0x0004) } + static var invalidContinuationState: SDPError { SDPError(rawValue: 0x0005) } + static var insufficientResources: SDPError { SDPError(rawValue: 0x0006) } +} + +extension SDPError: CustomStringConvertible { + + public var description: String { + switch self { + case .invalidVersion: return "Invalid/unsupported SDP version" + case .invalidServiceRecordHandle: return "Invalid service record handle" + case .invalidRequestSyntax: return "Invalid request syntax" + case .invalidPDUSize: return "Invalid PDU size" + case .invalidContinuationState: return "Invalid continuation state" + case .insufficientResources: return "Insufficient resources" + default: return "SDP error 0x" + String(rawValue, radix: 16, uppercase: true) + } + } +} From 3b7daf368acd0b37c331c16f82a15694ead6d2e7 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 2 Aug 2026 21:00:04 -0400 Subject: [PATCH 05/11] Add SDPPDUHeader, the fixed 5-byte SDP PDU header Covers PDU framing (PDU ID, transaction ID, parameter length) plus the data model and record codec. Per-request-type parameter layouts and continuation-state fragmentation belong with a socket-based session layer, not this pure data model, and aren't included here. --- Sources/BluetoothSDP/SDPPDU.swift | 94 +++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 Sources/BluetoothSDP/SDPPDU.swift diff --git a/Sources/BluetoothSDP/SDPPDU.swift b/Sources/BluetoothSDP/SDPPDU.swift new file mode 100644 index 000000000..1b04a4d27 --- /dev/null +++ b/Sources/BluetoothSDP/SDPPDU.swift @@ -0,0 +1,94 @@ +// +// SDPPDU.swift +// Bluetooth +// +// The fixed 5-byte header every SDP PDU starts with (SDP Protocol, +// Bluetooth Core Spec Vol 3, Part B, §4.2) — PDU ID, transaction ID, +// and the byte length of everything that follows. +// +// This covers PDU framing and the data model/record codec +// (`SDPDataElement`, `SDPServiceRecord`); it does not define the +// individual request/response parameter layouts (ServiceSearchRequest +// and friends) or continuation-state fragmentation/reassembly, which +// belong with a socket-based session layer, not this pure data model. +// + +import Bluetooth + +/// The PDU ID identifying an SDP PDU's kind (SDP Protocol, Bluetooth +/// Core Spec Vol 3, Part B, §4.2). +public struct SDPPDUID: RawRepresentable, Hashable, Sendable, Codable { + + public let rawValue: UInt8 + + public init(rawValue: UInt8) { + self.rawValue = rawValue + } +} + +public extension SDPPDUID { + + static var errorResponse: SDPPDUID { SDPPDUID(rawValue: 0x01) } + static var serviceSearchRequest: SDPPDUID { SDPPDUID(rawValue: 0x02) } + static var serviceSearchResponse: SDPPDUID { SDPPDUID(rawValue: 0x03) } + static var serviceAttributeRequest: SDPPDUID { SDPPDUID(rawValue: 0x04) } + static var serviceAttributeResponse: SDPPDUID { SDPPDUID(rawValue: 0x05) } + static var serviceSearchAttributeRequest: SDPPDUID { SDPPDUID(rawValue: 0x06) } + static var serviceSearchAttributeResponse: SDPPDUID { SDPPDUID(rawValue: 0x07) } +} + +extension SDPPDUID: ExpressibleByIntegerLiteral { + + public init(integerLiteral value: UInt8) { + self.init(rawValue: value) + } +} + +/// The fixed 5-byte header every SDP PDU starts with. +@frozen +public struct SDPPDUHeader: Equatable, Hashable, Sendable { + + /// The kind of PDU this is. + public var pduID: SDPPDUID + + /// Identifies a request/response pair; the response echoes the + /// request's transaction ID unchanged. + public var transactionID: UInt16 + + /// The byte length of everything following this header. + public var parameterLength: UInt16 + + public init(pduID: SDPPDUID, transactionID: UInt16, parameterLength: UInt16) { + self.pduID = pduID + self.transactionID = transactionID + self.parameterLength = parameterLength + } +} + +public extension SDPPDUHeader { + + static var length: Int { 5 } +} + +// MARK: - DataConvertible + +extension SDPPDUHeader: DataConvertible { + + public init?(data: Data) { + guard data.count == Self.length else { return nil } + let bytes = Array(data) + self.init( + pduID: SDPPDUID(rawValue: bytes[0]), + transactionID: UInt16(bigEndianBytes: bytes[1 ..< 3]), + parameterLength: UInt16(bigEndianBytes: bytes[3 ..< 5]) + ) + } + + public func append(to data: inout Data) { + data += pduID.rawValue + data += transactionID.bigEndianBytes + data += parameterLength.bigEndianBytes + } + + public var dataLength: Int { Self.length } +} From 4edc89defce637d622658defd7e3dc7e04855da8 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 2 Aug 2026 21:00:04 -0400 Subject: [PATCH 06/11] Add SDPServiceRecord, the SDP service record model Attributes keyed by SDPAttributeID, encoding/decoding to the flat data element sequence the wire format expects (attribute ID followed directly by its value, sorted by ID), plus convenience accessors for the universal attributes (service class list, protocol descriptor list, profile descriptor list, and the language-base-relative name/description/provider strings). --- Sources/BluetoothSDP/SDPServiceRecord.swift | 153 ++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 Sources/BluetoothSDP/SDPServiceRecord.swift diff --git a/Sources/BluetoothSDP/SDPServiceRecord.swift b/Sources/BluetoothSDP/SDPServiceRecord.swift new file mode 100644 index 000000000..00820309a --- /dev/null +++ b/Sources/BluetoothSDP/SDPServiceRecord.swift @@ -0,0 +1,153 @@ +// +// SDPServiceRecord.swift +// Bluetooth +// +// An SDP service record: the set of attributes describing one +// service, as returned by a ServiceAttributeResponse / +// ServiceSearchAttributeResponse, or built up to register one. +// +// On the wire, a record is a data element sequence containing, for +// each attribute, its ID (encoded as a UINT16 data element) directly +// followed by its value — not further nested into pairs. +// + +import Bluetooth + +/// An SDP service record: a service's attributes, keyed by attribute ID. +public struct SDPServiceRecord: Hashable, Sendable { + + public var attributes: [SDPAttributeID: SDPDataElement] + + public init(attributes: [SDPAttributeID: SDPDataElement] = [:]) { + self.attributes = attributes + } + + public subscript(attributeID: SDPAttributeID) -> SDPDataElement? { + get { attributes[attributeID] } + set { attributes[attributeID] = newValue } + } +} + +// MARK: - DataConvertible + +extension SDPServiceRecord: DataConvertible { + + public init?(data: Data) { + guard let sequence = SDPDataElement(data: data), + case let .sequence(elements) = sequence, + elements.count % 2 == 0 + else { return nil } + + var attributes: [SDPAttributeID: SDPDataElement] = [:] + attributes.reserveCapacity(elements.count / 2) + var index = elements.startIndex + while index < elements.endIndex { + guard case let .unsignedInt16(rawAttributeID) = elements[index] else { return nil } + let value = elements[index + 1] + attributes[SDPAttributeID(rawValue: rawAttributeID)] = value + index += 2 + } + self.init(attributes: attributes) + } + + public func append(to data: inout Data) { + sequenceElement.append(to: &data) + } + + public var dataLength: Int { + sequenceElement.dataLength + } + + /// This record's attributes, sorted by ID, as the flat data element + /// sequence the wire format expects. + private var sequenceElement: SDPDataElement { + var elements: [SDPDataElement] = [] + elements.reserveCapacity(attributes.count * 2) + for (attributeID, value) in attributes.sorted(by: { $0.key.rawValue < $1.key.rawValue }) { + elements.append(.unsignedInt16(attributeID.rawValue)) + elements.append(value) + } + return .sequence(elements) + } +} + +// MARK: - Well-known attribute accessors + +public extension SDPServiceRecord { + + /// The 32-bit handle a server assigns this record — usually only + /// meaningful on records read back from a server, not ones being + /// built for registration. + var handle: UInt32? { + get { + guard case let .unsignedInt32(value)? = self[.serviceRecordHandle] else { return nil } + return value + } + set { + self[.serviceRecordHandle] = newValue.map { .unsignedInt32($0) } + } + } + + /// The service class UUIDs, most specific first. + var serviceClassIDList: [BluetoothUUID]? { + get { uuidList(for: .serviceClassIDList) } + set { self[.serviceClassIDList] = newValue.map { .sequence($0.map(SDPDataElement.uuid)) } } + } + + /// The Bluetooth profile UUIDs (with version) this record conforms + /// to. + var bluetoothProfileDescriptorList: [(uuid: BluetoothUUID, version: UInt16)]? { + guard case let .sequence(profiles)? = self[.bluetoothProfileDescriptorList] else { return nil } + return profiles.compactMap { profile in + guard case let .sequence(pair) = profile, + pair.count == 2, + case let .uuid(uuid) = pair[0], + case let .unsignedInt16(version) = pair[1] + else { return nil } + return (uuid, version) + } + } + + /// This record's primary language's base offset for the + /// string attributes below (``SDPAttributeID/primaryLanguageBase`` + /// if the record has no `languageBaseAttributeIDList`). + private var primaryLanguageBase: UInt16 { + guard case let .sequence(triples)? = self[.languageBaseAttributeIDList], + triples.count >= 3, + case let .unsignedInt16(base) = triples[2] + else { return SDPAttributeID.primaryLanguageBase } + return base + } + + var serviceName: String? { + get { string(.serviceName) } + set { setString(.serviceName, newValue) } + } + + var serviceDescription: String? { + get { string(.serviceDescription) } + set { setString(.serviceDescription, newValue) } + } + + var providerName: String? { + get { string(.providerName) } + set { setString(.providerName, newValue) } + } + + private func string(_ offset: SDPAttributeID.PrimaryLanguageOffset) -> String? { + guard case let .text(value)? = self[.string(offset, base: primaryLanguageBase)] else { return nil } + return value + } + + private mutating func setString(_ offset: SDPAttributeID.PrimaryLanguageOffset, _ newValue: String?) { + self[.string(offset, base: primaryLanguageBase)] = newValue.map { .text($0) } + } + + private func uuidList(for attributeID: SDPAttributeID) -> [BluetoothUUID]? { + guard case let .sequence(elements)? = self[attributeID] else { return nil } + return elements.compactMap { element in + guard case let .uuid(uuid) = element else { return nil } + return uuid + } + } +} From be0d74e410027886f66a5f1544c604479c6fbc97 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 2 Aug 2026 21:00:11 -0400 Subject: [PATCH 07/11] Add the BluetoothSDP target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A normal, always-on target like BluetoothGAP/BluetoothGATT/BluetoothHCI — no C, no environment-variable gating, no @c bindings; just an idiomatic Swift module depending only on Bluetooth. --- Package.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index ceecd45e2..be0e8cb11 100644 --- a/Package.swift +++ b/Package.swift @@ -46,6 +46,11 @@ var package = Package( name: "BluetoothHCI", type: libraryType, targets: ["BluetoothHCI"] + ), + .library( + name: "BluetoothSDP", + type: libraryType, + targets: ["BluetoothSDP"] ) ], targets: [ @@ -83,6 +88,12 @@ var package = Package( "BluetoothGAP" ] ), + .target( + name: "BluetoothSDP", + dependencies: [ + "Bluetooth" + ] + ), .testTarget( name: "BluetoothTests", dependencies: [ @@ -102,6 +113,10 @@ var package = Package( .target( name: "BluetoothHCI", condition: .when(platforms: [.macOS, .linux, .macCatalyst, .windows, .android]) + ), + .target( + name: "BluetoothSDP", + condition: .when(platforms: [.macOS, .linux, .macCatalyst, .windows, .android]) ) ] ) @@ -126,7 +141,7 @@ if embeddedFoundation { name: "FoundationEmbedded", package: "swift-embedded-foundation" ) - for name in ["Bluetooth", "BluetoothGAP", "BluetoothGATT", "BluetoothHCI"] { + for name in ["Bluetooth", "BluetoothGAP", "BluetoothGATT", "BluetoothHCI", "BluetoothSDP"] { guard let index = package.targets.firstIndex(where: { $0.name == name }) else { fatalError("Missing target \(name)") } From efc232d7bb6f8a4047c8a7bd29d792d8bc30b356 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 2 Aug 2026 21:00:11 -0400 Subject: [PATCH 08/11] Add tests for the SDP data model, record, PDU header, and attribute/error types --- Tests/BluetoothTests/SDPTests.swift | 151 ++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 Tests/BluetoothTests/SDPTests.swift diff --git a/Tests/BluetoothTests/SDPTests.swift b/Tests/BluetoothTests/SDPTests.swift new file mode 100644 index 000000000..2b7278287 --- /dev/null +++ b/Tests/BluetoothTests/SDPTests.swift @@ -0,0 +1,151 @@ +// +// SDPTests.swift +// BluetoothTests +// + +#if canImport(BluetoothSDP) +import Testing +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif +import Bluetooth +@testable import BluetoothSDP + +@Suite struct SDPTests { + + @Test func dataElementRoundTrip() { + + let elements: [SDPDataElement] = [ + .null, + .unsignedInt8(0xFF), + .unsignedInt16(0x1234), + .unsignedInt32(0xDEADBEEF), + .unsignedInt64(0x0102030405060708), + .unsignedInt128(UInt128(bigEndianBytes: Array(0 ..< 16))), + .signedInt8(-1), + .signedInt16(-1234), + .signedInt32(-1), + .signedInt64(-1), + .uuid(.bit16(0x1101)), + .uuid(.bit32(0x00001101)), + .uuid(.bit128(UInt128(bigEndianBytes: Array(0 ..< 16)))), + .text("Serial Port"), + .text(""), + .boolean(true), + .boolean(false), + .url("https://www.bluetooth.com"), + .sequence([.uuid(.bit16(0x1101)), .unsignedInt16(1)]), + .alternative([.unsignedInt8(1), .unsignedInt16(2)]) + ] + + for element in elements { + let data = Data(element) + #expect(data.count == element.dataLength) + let decoded = SDPDataElement(data: data) + #expect(decoded == element) + } + } + + @Test func nestedSequence() { + + let element = SDPDataElement.sequence([ + .sequence([.uuid(.bit16(0x0100))]), + .sequence([.uuid(.bit16(0x0003)), .unsignedInt8(9)]) + ]) + + let data = Data(element) + #expect(SDPDataElement(data: data) == element) + } + + @Test func largeTextUsesWiderLengthField() { + + // 300 bytes doesn't fit an 8-bit length field (max 255), so + // this must pick the 16-bit size descriptor, not truncate. + let text = String(repeating: "a", count: 300) + let element = SDPDataElement.text(text) + let data = Data(element) + + // header (1) + 16-bit length field (2) + payload + #expect(data.count == 1 + 2 + 300) + #expect(SDPDataElement(data: data) == element) + } + + @Test func malformedDataFailsToDecode() { + + #expect(SDPDataElement(data: Data()) == nil) + // UUID tag claiming 16 bytes but only 2 provided + #expect(SDPDataElement(data: Data([0x1C, 0x11, 0x01])) == nil) + // trailing byte after a complete element + #expect(SDPDataElement(data: Data([0x00, 0x00])) == nil) + } + + @Test func serviceRecordRoundTrip() { + + var record = SDPServiceRecord() + record.handle = 0x00010001 + record.serviceClassIDList = [.bit16(0x1101)] + record.serviceName = "Serial Port" + record.serviceDescription = "COM Port" + record.providerName = "PureSwift" + record[.protocolDescriptorList] = .sequence([ + .sequence([.uuid(.bit16(0x0100))]), + .sequence([.uuid(.bit16(0x0003)), .unsignedInt8(1)]) + ]) + + let data = Data(record) + let decoded = SDPServiceRecord(data: data) + + #expect(decoded?.handle == record.handle) + #expect(decoded?.serviceClassIDList == record.serviceClassIDList) + #expect(decoded?.serviceName == record.serviceName) + #expect(decoded?.serviceDescription == record.serviceDescription) + #expect(decoded?.providerName == record.providerName) + #expect(decoded == record) + } + + @Test func serviceRecordAttributesSortedOnEncode() { + + var record = SDPServiceRecord() + record[.serviceInfoTimeToLive] = .unsignedInt32(30) + record[.serviceRecordHandle] = .unsignedInt32(1) + record[.serviceClassIDList] = .sequence([.uuid(.bit16(0x1101))]) + + let data = Data(record) + #expect(SDPServiceRecord(data: data) == record) + + // first attribute ID element, right after the outer sequence's + // own header bytes (SEQ8 tag + 1-byte length = 2 bytes), must + // be the smallest one, 0x0000 — encoded as a UINT16 element: + // tag 0x09, then the two value bytes. + let bytes = [UInt8](data) + #expect(Array(bytes[2 ..< 5]) == [0x09, 0x00, 0x00]) + } + + @Test func attributeID() { + + #expect(SDPAttributeID.serviceClassIDList.rawValue == 0x0001) + #expect(SDPAttributeID.protocolDescriptorList.rawValue == 0x0004) + #expect(SDPAttributeID.string(.serviceName).rawValue == 0x0100) + #expect(SDPAttributeID.string(.serviceName, base: 0x0200).rawValue == 0x0200) + #expect(SDPAttributeID.serviceClassIDList.description == "0x0001") + } + + @Test func pduHeaderRoundTrip() { + + let header = SDPPDUHeader(pduID: .serviceSearchRequest, transactionID: 0x0102, parameterLength: 0x0304) + let data = Data(header) + + #expect(data.count == SDPPDUHeader.length) + #expect([UInt8](data) == [0x02, 0x01, 0x02, 0x03, 0x04]) + #expect(SDPPDUHeader(data: data) == header) + } + + @Test func error() { + + #expect(SDPError.invalidPDUSize.rawValue == 0x0004) + #expect(SDPError.invalidPDUSize.description == "Invalid PDU size") + } +} +#endif From 879de2d0cd56252abd0e90b81514121a0b161f93 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 2 Aug 2026 21:10:12 -0400 Subject: [PATCH 09/11] Fix macOS CI: UInt128's FixedWidthInteger conformance is macOS 15+ The generic FixedWidthInteger-constrained big-endian helper doesn't compile for UInt128 at this package's macOS 10.15 deployment target, since this package's UInt128 only conforms to FixedWidthInteger under @available(macOS 15, ...) (a hand-rolled struct backs it before the native stdlib type existed). Dedicated, non-generic UInt128 overloads go through the always-available ByteValue tuple accessor instead. --- Sources/BluetoothSDP/SDPBigEndian.swift | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Sources/BluetoothSDP/SDPBigEndian.swift b/Sources/BluetoothSDP/SDPBigEndian.swift index 99abec376..736585a53 100644 --- a/Sources/BluetoothSDP/SDPBigEndian.swift +++ b/Sources/BluetoothSDP/SDPBigEndian.swift @@ -10,6 +10,16 @@ // module is read and written explicitly, most-significant-byte-first, // through these two small generic helpers. // +// UInt128 gets its own non-generic overloads below rather than using +// these: this package's `UInt128` only conforms to `FixedWidthInteger` +// under `@available(macOS 15, ...)` (on Apple platforms before the +// native stdlib type, it's a hand-rolled struct), so a generic +// `FixedWidthInteger`-constrained call fails to compile for it on +// earlier deployment targets. The concrete overloads instead go +// through `ByteValue`'s tuple accessor, which has no such gate. +// + +import Bluetooth internal extension FixedWidthInteger { @@ -29,3 +39,22 @@ internal extension FixedWidthInteger { } } } + +internal extension UInt128 { + + /// Parses `self` from 16 big-endian (network byte order) bytes. + init(bigEndianBytes bytes: Bytes) where Bytes.Element == UInt8 { + let b = Array(bytes) + self = UInt128(bytes: ( + b[15], b[14], b[13], b[12], b[11], b[10], b[9], b[8], + b[7], b[6], b[5], b[4], b[3], b[2], b[1], b[0] + )) + } + + /// The big-endian (network byte order) bytes of `self`, most + /// significant byte first. + var bigEndianBytes: [UInt8] { + let b = bytes + return [b.15, b.14, b.13, b.12, b.11, b.10, b.9, b.8, b.7, b.6, b.5, b.4, b.3, b.2, b.1, b.0] + } +} From ec6cfc131928aa40615c430c821504c26f5ad67d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 2 Aug 2026 21:10:12 -0400 Subject: [PATCH 10/11] Add an explicit UInt128 big-endian byte-order correctness check Round-tripping alone can't distinguish correct big-endian byte order from a consistently-reversed implementation, since encode/decode are exact inverses of each other by construction either way. This checks against a known numeric value instead. --- Tests/BluetoothTests/SDPTests.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Tests/BluetoothTests/SDPTests.swift b/Tests/BluetoothTests/SDPTests.swift index 2b7278287..2296baefc 100644 --- a/Tests/BluetoothTests/SDPTests.swift +++ b/Tests/BluetoothTests/SDPTests.swift @@ -15,6 +15,21 @@ import Bluetooth @Suite struct SDPTests { + @Test func uint128BigEndianBytes() { + + // 15 leading zero bytes then 0x01, most-significant byte + // first, must equal the numeric value 1 — not just round-trip + // with itself, which a consistently-reversed implementation + // would also pass. + let one = [UInt8](repeating: 0, count: 15) + [0x01] + #expect(UInt128(bigEndianBytes: one) == 1) + #expect((1 as UInt128).bigEndianBytes == one) + + let value = UInt128(bigEndianBytes: Array(0 ..< 16)) + #expect(value.bigEndianBytes == Array(0 ..< 16)) + #expect(value == UInt128(0x000102030405060708090A0B0C0D0E0F)) + } + @Test func dataElementRoundTrip() { let elements: [SDPDataElement] = [ From 6a3b6331ceee372386ba149d4404ce37a621e637 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 2 Aug 2026 21:25:50 -0400 Subject: [PATCH 11/11] Fix macOS CI: avoid UInt128 integer literals in the new test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same availability gate as the library fix — UInt128's ExpressibleByIntegerLiteral and BinaryInteger conformances are macOS 15+ only here. Builds the comparison value via the unconditionally-available ByteValue tuple initializer instead. --- Tests/BluetoothTests/SDPTests.swift | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/Tests/BluetoothTests/SDPTests.swift b/Tests/BluetoothTests/SDPTests.swift index 2296baefc..26c3fc907 100644 --- a/Tests/BluetoothTests/SDPTests.swift +++ b/Tests/BluetoothTests/SDPTests.swift @@ -17,17 +17,23 @@ import Bluetooth @Test func uint128BigEndianBytes() { - // 15 leading zero bytes then 0x01, most-significant byte - // first, must equal the numeric value 1 — not just round-trip - // with itself, which a consistently-reversed implementation - // would also pass. - let one = [UInt8](repeating: 0, count: 15) + [0x01] - #expect(UInt128(bigEndianBytes: one) == 1) - #expect((1 as UInt128).bigEndianBytes == one) + // Checked against a value built independently via the + // always-available `ByteValue` tuple initializer (not an + // integer literal — UInt128's ExpressibleByIntegerLiteral and + // BinaryInteger conformances are macOS-15+-only on this + // package's older deployment target), so this catches actual + // byte-order mistakes rather than only self-consistency (a + // reversed-but-consistent implementation would pass a pure + // round-trip check too). + let oneBytes = [UInt8](repeating: 0, count: 15) + [0x01] + let one = UInt128(bytes: (1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) + #expect(UInt128(bigEndianBytes: oneBytes) == one) + #expect(one.bigEndianBytes == oneBytes) let value = UInt128(bigEndianBytes: Array(0 ..< 16)) #expect(value.bigEndianBytes == Array(0 ..< 16)) - #expect(value == UInt128(0x000102030405060708090A0B0C0D0E0F)) + let expected = UInt128(bytes: (15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0)) + #expect(value == expected) } @Test func dataElementRoundTrip() {