From 9113c1b975997b10529c38816728ec7d03b55a18 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Mon, 20 Jul 2026 20:57:17 -0400 Subject: [PATCH 1/5] Add didConfirm callback to PeripheralManager protocol Adds a didConfirm(Central, UInt16) callback so callers can be notified when a central acknowledges an indication, distinct from didWrite which only covers writes initiated by the central. Backends document what, if anything, actually triggers it, since not every transport can observe ATT-level indication confirmations with central/characteristic granularity. --- Sources/GATT/PeripheralProtocol.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/GATT/PeripheralProtocol.swift b/Sources/GATT/PeripheralProtocol.swift index ad5919b..ebd9f3e 100644 --- a/Sources/GATT/PeripheralProtocol.swift +++ b/Sources/GATT/PeripheralProtocol.swift @@ -59,12 +59,22 @@ public protocol PeripheralManager { /// Callback to handle post-write actions for GATT write requests. var didWrite: ((GATTWriteConfirmation) -> ())? { get set } - /// Callback to handle when a central connects. +/// Callback to handle when a central connects. var didConnect: ((Central) -> ())? { get set } /// Callback to handle when a central disconnects. var didDisconnect: ((Central) -> ())? { get set } + /// Callback invoked when a central acknowledges (confirms) an indication for the + /// specified characteristic handle. + /// + /// Indications differ from notifications in that the central sends an ATT + /// confirmation once it receives the value, letting the peripheral know delivery + /// succeeded. Not every backend is able to observe that confirmation with + /// central / characteristic granularity; see the documentation on each + /// conforming type for what, if anything, triggers this callback. + var didConfirm: ((Central, UInt16) -> ())? { get set } + /// Modify the value of a characteristic, optionally emiting notifications if configured on active connections. func write(_ newValue: Data, forCharacteristic handle: UInt16) From fc08597617b5b7ab777db29c14537feed22c9848 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Mon, 20 Jul 2026 20:57:23 -0400 Subject: [PATCH 2/5] Add no-op didConfirm property to GATTPeripheral Bluetooth.GATTServer already receives ATT indication confirmations internally (see send(_:response:) in GATTServer.swift) but only logs them; its public Callback struct exposes willRead, willWrite, and didWrite only, with no hook for confirmations. This property exists solely to satisfy PeripheralManager conformance and is documented as never invoked until GATTServer.Callback gains a corresponding hook upstream. --- Sources/GATT/GATTPeripheral.swift | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Sources/GATT/GATTPeripheral.swift b/Sources/GATT/GATTPeripheral.swift index dcd24f5..32da048 100644 --- a/Sources/GATT/GATTPeripheral.swift +++ b/Sources/GATT/GATTPeripheral.swift @@ -94,6 +94,26 @@ public final class GATTPeripheral : Periphe } } + /// Callback invoked when a central acknowledges (confirms) an indication for the + /// specified characteristic handle. + /// + /// - Warning: Never invoked. `Bluetooth.GATTServer`, the ATT server implementation + /// backing this type, already receives ATT indication confirmations + /// (`ATTHandleValueConfirmation`) from `send(_:response:)`, but only logs them — + /// it does not forward them through its public `Callback` struct, which exposes + /// only `willRead`, `willWrite`, and `didWrite`. Surfacing confirmations here + /// requires adding a corresponding hook (e.g. `didConfirm`) to `GATTServer.Callback` + /// upstream in the `PureSwift/Bluetooth` package; until then this property is kept + /// only to satisfy `PeripheralManager` conformance. + public var didConfirm: ((Central, UInt16) -> ())? { + get { + storage.didConfirm + } + set { + storage.didConfirm = newValue + } + } + public var connections: Set { Set(storage.connections.values.lazy.map { $0.central }) } @@ -579,6 +599,8 @@ internal extension GATTPeripheral { var didDisconnect: ((Central) -> ())? + var didConfirm: ((Central, UInt16) -> ())? + var log: (@Sendable (String) -> ())? var socket: Socket? From cf1acc744afbb2ea39b28945e458a97eaf6c721f Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Mon, 20 Jul 2026 20:57:30 -0400 Subject: [PATCH 3/5] Implement best-effort didConfirm on DarwinPeripheral CoreBluetooth does not report ATT indication confirmations directly; the closest signal is peripheralManagerIsReady(toUpdateSubscribers:), which fires once the shared transmit queue has space again after updateValue(_:for:onSubscribedCentrals:) returns false. Since CoreBluetooth withholds further updates to a central while an indication to it is unconfirmed, use that as a heuristic: invoke didConfirm from write(_:forCharacteristic:for:) when a targeted update had to wait on the queue before succeeding. This also fixes that method to target the specified central instead of silently broadcasting to all subscribers. --- Sources/DarwinGATT/DarwinPeripheral.swift | 43 ++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/Sources/DarwinGATT/DarwinPeripheral.swift b/Sources/DarwinGATT/DarwinPeripheral.swift index 931843a..3e76f24 100644 --- a/Sources/DarwinGATT/DarwinPeripheral.swift +++ b/Sources/DarwinGATT/DarwinPeripheral.swift @@ -44,6 +44,27 @@ public final class DarwinPeripheral: PeripheralManager, @unchecked Sendable { public var didDisconnect: ((Central) -> ())? + /// Callback invoked when a central acknowledges (confirms) an indication for the + /// specified characteristic handle. + /// + /// - Note: CoreBluetooth does not report ATT-level indication confirmations + /// directly; `CBPeripheralManagerDelegate` has no `didConfirm`-style method. + /// The only related signal is `peripheralManagerIsReady(toUpdateSubscribers:)`, + /// which fires when the shared transmit queue (used for both notifications and + /// indications, across all subscribed centrals) has space again after a prior + /// `updateValue(_:for:onSubscribedCentrals:)` call returned `false`. Because + /// CoreBluetooth will not accept another update for a central while an + /// indication to that central is unconfirmed, this callback is invoked as a + /// best-effort proxy: only for calls to `write(_:forCharacteristic:for:)` (a + /// single, explicit central) that initially failed to enqueue and only + /// succeeded after waiting for `peripheralManagerIsReady`. This is a heuristic, + /// not a verified per-characteristic confirmation — the queue can also free up + /// for reasons unrelated to that specific central or characteristic (e.g. a + /// plain notification draining, or another central's indication being + /// confirmed), and it is never invoked for the broadcast `write(_:forCharacteristic:)` + /// since no single central can be attributed there. + public var didConfirm: ((Central, UInt16) -> ())? + public var stateChanged: ((DarwinBluetoothState) -> ())? public var connections: Set { @@ -173,7 +194,27 @@ public final class DarwinPeripheral: PeripheralManager, @unchecked Sendable { } public func write(_ newValue: Data, forCharacteristic handle: UInt16, for central: Central) { - write(newValue, forCharacteristic: handle) // per-connection database not supported on Darwin + // update GATT DB (shared; per-connection database not supported on Darwin) + database[characteristic: handle] = newValue + // send notification/indication to only the specified central + if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) { + Task { + var didNotify = updateValue(newValue, forCharacteristic: handle, centrals: [central]) + var didWaitForTransmitQueue = false + while didNotify == false { + didWaitForTransmitQueue = true + await waitPeripheralReadyUpdateSubcribers() + didNotify = updateValue(newValue, forCharacteristic: handle, centrals: [central]) + } + // Best-effort indication confirmation heuristic. + // See the `didConfirm` documentation for its limitations. + if didWaitForTransmitQueue { + didConfirm?(central, handle) + } + } + } else { + updateValue(newValue, forCharacteristic: handle, centrals: [central]) + } } public func value(for characteristicHandle: UInt16, central: Central) -> Data { From 7f9a4ec6558f8ac75115f068d0afbc631a0de645 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Mon, 20 Jul 2026 23:41:58 -0400 Subject: [PATCH 4/5] Require Bluetooth 8.0.0 --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index db6ccd8..f2929ce 100644 --- a/Package.swift +++ b/Package.swift @@ -35,7 +35,7 @@ var package = Package( dependencies: [ .package( url: "https://github.com/PureSwift/Bluetooth.git", - from: "7.5.0" + from: "8.0.0" ) ], targets: [ From c8f1847fe670bca3469b9108fe303f6fa64d3a80 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Mon, 20 Jul 2026 23:41:58 -0400 Subject: [PATCH 5/5] Forward GATTServer indication confirmations to didConfirm --- Sources/GATT/GATTPeripheral.swift | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Sources/GATT/GATTPeripheral.swift b/Sources/GATT/GATTPeripheral.swift index 32da048..c6e24dc 100644 --- a/Sources/GATT/GATTPeripheral.swift +++ b/Sources/GATT/GATTPeripheral.swift @@ -96,15 +96,6 @@ public final class GATTPeripheral : Periphe /// Callback invoked when a central acknowledges (confirms) an indication for the /// specified characteristic handle. - /// - /// - Warning: Never invoked. `Bluetooth.GATTServer`, the ATT server implementation - /// backing this type, already receives ATT indication confirmations - /// (`ATTHandleValueConfirmation`) from `send(_:response:)`, but only logs them — - /// it does not forward them through its public `Callback` struct, which exposes - /// only `willRead`, `willWrite`, and `didWrite`. Surfacing confirmations here - /// requires adding a corresponding hook (e.g. `didConfirm`) to `GATTServer.Callback` - /// upstream in the `PureSwift/Bluetooth` package; until then this property is kept - /// only to satisfy `PeripheralManager` conformance. public var didConfirm: ((Central, UInt16) -> ())? { get { storage.didConfirm @@ -378,6 +369,9 @@ internal extension GATTPeripheral { callback.didWrite = { (uuid, handle, value) in self.didWrite(central: central, uuid: uuid, handle: handle, value: value) } + callback.didConfirm = { (uuid, handle) in + self.didConfirm(central: central, uuid: uuid, handle: handle) + } #else callback.willRead = { [weak self] in self?.willRead(central: central, uuid: $0, handle: $1, value: $2, offset: $3) @@ -388,6 +382,9 @@ internal extension GATTPeripheral { callback.didWrite = { [weak self] (uuid, handle, value) in self?.didWrite(central: central, uuid: uuid, handle: handle, value: value) } + callback.didConfirm = { [weak self] (uuid, handle) in + self?.didConfirm(central: central, uuid: uuid, handle: handle) + } #endif return callback } @@ -450,6 +447,10 @@ internal extension GATTPeripheral { // notify delegate didWrite?(confirmation) } + + func didConfirm(central: Central, uuid: BluetoothUUID, handle: UInt16) { + didConfirm?(central, handle) + } /// Accept a pending connection, without blocking. @discardableResult