Skip to content

Commit 6ca8939

Browse files
authored
Merge pull request #12 from StarryInternet/peripheral-publisher-types
Refactor `Peripheral` for better testability, and return actual `Data` or `Void` for reads/writes.
2 parents 4c55ec3 + c9350e2 commit 6ca8939

6 files changed

Lines changed: 309 additions & 327 deletions

File tree

Sources/CombineCoreBluetooth/Internal/PassthroughBacked.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ struct PassthroughBacked<Output> {
1515
subject.eraseToAnyPublisher()
1616
}
1717

18+
var projectedValue: PassthroughBacked<Output> {
19+
self
20+
}
21+
1822
func send(_ value: Output) {
1923
subject.send(value)
2024
}

Sources/CombineCoreBluetooth/Internal/Publisher+Extensions.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ extension Publisher {
2020
})
2121
}
2222

23-
2423
/// Applies the predicate to the first item in the Output's tuple; if it matches, take the first element from that and pass it along or throw any errors that are present.
2524
func filterFirstValueOrThrow<Value>(where predicate: @escaping (Value, Error?) -> Bool) -> AnyPublisher<Value, Error> where Output == (Value, Error?) {
2625
first(where: predicate)

Sources/CombineCoreBluetooth/Peripheral/Convenience+Peripheral.swift

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ extension Peripheral {
5555
.flatMap { characteristic in
5656
self.readValue(for: characteristic)
5757
}
58-
.map(\.value)
5958
.eraseToAnyPublisher()
6059
}
6160

@@ -64,7 +63,23 @@ extension Peripheral {
6463
.flatMap { characteristic in
6564
self.writeValue(value, for: characteristic, type: writeType)
6665
}
67-
.map { _ in }
66+
.eraseToAnyPublisher()
67+
}
68+
69+
/// Returns a long-lived publisher that receives all value updates for the given characteristic. Allows for many listeners to be updated for a single read, or for indications/notifications of a characteristic.
70+
/// - Parameter characteristic: The characteristic to listen to for updates.
71+
/// - Returns: A publisher that will listen to updates to the given characteristic. Continues indefinitely, unless an error is encountered.
72+
public func listenForUpdates(on characteristic: CBCharacteristic) -> AnyPublisher<Data?, Error> {
73+
delegate
74+
.didUpdateValueForCharacteristic
75+
// not limiting to `.first()` here as callers may want long-lived listening for value changes
76+
.filter({ (readCharacteristic, error) -> Bool in
77+
return readCharacteristic.uuid == characteristic.uuid
78+
})
79+
.tryMap {
80+
if let error = $1 { throw error }
81+
return $0.value
82+
}
6883
.eraseToAnyPublisher()
6984
}
7085
}

Sources/CombineCoreBluetooth/Peripheral/Interface+Peripheral.swift

Lines changed: 210 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import Foundation
2+
import CoreBluetooth
23

34
/// The `CombineCoreBluetooth` wrapper around `CBPeripheral`.
45
public struct Peripheral {
56
let rawValue: CBPeripheral?
7+
let delegate: Delegate
68

79
var _name: () -> String?
810
var _identifier: () -> UUID
@@ -13,19 +15,18 @@ public struct Peripheral {
1315
@available(macOS, unavailable)
1416
var _ancsAuthorized: () -> Bool
1517

16-
var _readRSSI: () -> AnyPublisher<Double, Error>
17-
var _discoverServices: (_ serviceUUIDs: [CBUUID]?) -> AnyPublisher<[CBService], Error>
18-
var _discoverIncludedServices: (_ includedServiceUUIDs: [CBUUID]?, _ service: CBService) -> AnyPublisher<[CBService]?, Error>
19-
var _discoverCharacteristics: (_ characteristicUUIDs: [CBUUID]?, _ service: CBService) -> AnyPublisher<[CBCharacteristic], Error>
20-
var _readValueForCharacteristic: (_ characteristic: CBCharacteristic) -> AnyPublisher<CBCharacteristic, Error>
18+
var _readRSSI: () -> Void
19+
var _discoverServices: (_ serviceUUIDs: [CBUUID]?) -> Void
20+
var _discoverIncludedServices: (_ includedServiceUUIDs: [CBUUID]?, _ service: CBService) -> Void
21+
var _discoverCharacteristics: (_ characteristicUUIDs: [CBUUID]?, _ service: CBService) -> Void
22+
var _readValueForCharacteristic: (_ characteristic: CBCharacteristic) -> Void
2123
var _maximumWriteValueLength: (_ type: CBCharacteristicWriteType) -> Int
22-
var _writeValueForCharacteristic: (_ data: Data, _ characteristic: CBCharacteristic, _ type: CBCharacteristicWriteType) -> AnyPublisher<CBCharacteristic, Error>
23-
var _setNotifyValue: (_ enabled: Bool, _ characteristic: CBCharacteristic) -> AnyPublisher<CBCharacteristic, Error>
24-
var _discoverDescriptors: (_ characteristic: CBCharacteristic) -> AnyPublisher<[CBDescriptor]?, Error>
25-
var _readValueForDescriptor: (_ descriptor: CBDescriptor) -> AnyPublisher<CBDescriptor, Error>
26-
var _writeValueForDescriptor: (_ data: Data, _ descriptor: CBDescriptor) -> AnyPublisher<CBDescriptor, Error>
27-
var _openL2CAPChannel: (_ PSM: CBL2CAPPSM) -> AnyPublisher<L2CAPChannel, Error>
28-
var _listenForUpdatesToCharacteristic: (_ characteristic: CBCharacteristic) -> AnyPublisher<CBCharacteristic, Error>
24+
var _writeValueForCharacteristic: (_ data: Data, _ characteristic: CBCharacteristic, _ type: CBCharacteristicWriteType) -> Void
25+
var _setNotifyValue: (_ enabled: Bool, _ characteristic: CBCharacteristic) -> Void
26+
var _discoverDescriptors: (_ characteristic: CBCharacteristic) -> Void
27+
var _readValueForDescriptor: (_ descriptor: CBDescriptor) -> Void
28+
var _writeValueForDescriptor: (_ data: Data, _ descriptor: CBDescriptor) -> Void
29+
var _openL2CAPChannel: (_ PSM: CBL2CAPPSM) -> Void
2930

3031
public var isReadyToSendWriteWithoutResponse: AnyPublisher<Void, Never>
3132
public var nameUpdates: AnyPublisher<String?, Never>
@@ -57,55 +58,234 @@ public struct Peripheral {
5758
}
5859

5960
public func readRSSI() -> AnyPublisher<Double, Error> {
60-
_readRSSI()
61+
delegate
62+
.didReadRSSI
63+
.tryMap { result in
64+
try result.get()
65+
}
66+
.first()
67+
.handleEvents(receiveSubscription: { [_readRSSI] _ in
68+
_readRSSI()
69+
})
70+
.shareCurrentValue()
71+
.eraseToAnyPublisher()
6172
}
6273

6374
public func discoverServices(_ serviceUUIDs: [CBUUID]?) -> AnyPublisher<[CBService], Error> {
64-
_discoverServices(serviceUUIDs)
75+
delegate
76+
.didDiscoverServices
77+
.filterFirstValueOrThrow(where: { services in
78+
// nil identifiers means we want to discover anything we can
79+
guard let identifiers = serviceUUIDs else { return true }
80+
// Only progress if the peripheral contains all the services we are looking for.
81+
let neededUUIDs = Set(identifiers)
82+
let foundUUIDs = Set(services.map(\.uuid))
83+
let allFound = foundUUIDs.isSuperset(of: neededUUIDs)
84+
return allFound
85+
})
86+
.handleEvents(receiveSubscription: { [_discoverServices] _ in
87+
_discoverServices(serviceUUIDs)
88+
})
89+
.shareCurrentValue()
90+
.eraseToAnyPublisher()
6591
}
6692

6793
public func discoverIncludedServices(_ serviceUUIDS: [CBUUID]?, for service: CBService) -> AnyPublisher<[CBService]?, Error> {
68-
_discoverIncludedServices(serviceUUIDS, service)
94+
delegate
95+
.didDiscoverIncludedServices
96+
.filterFirstValueOrThrow(where: { discoveredService in
97+
// ignore characteristics from services we're not interested in.
98+
guard discoveredService.uuid == service.uuid else { return false }
99+
// nil identifiers means we want to discover anything we can
100+
guard let identifiers = serviceUUIDS else { return true }
101+
// Only progress if the discovered service contains all the included services we are looking for.
102+
let neededUUIDs = Set(identifiers)
103+
let foundUUIDs = Set((discoveredService.includedServices ?? []).map(\.uuid))
104+
let allFound = foundUUIDs.isSuperset(of: neededUUIDs)
105+
return allFound
106+
})
107+
.map(\.includedServices)
108+
.handleEvents(receiveSubscription: { [_discoverIncludedServices] _ in
109+
_discoverIncludedServices(serviceUUIDS, service)
110+
})
111+
.shareCurrentValue()
112+
.eraseToAnyPublisher()
69113
}
70114

71115
public func discoverCharacteristics(_ characteristicUUIDs: [CBUUID]?, for service: CBService) -> AnyPublisher<[CBCharacteristic], Error> {
72-
_discoverCharacteristics(characteristicUUIDs, service)
116+
delegate
117+
.didDiscoverCharacteristics
118+
.filterFirstValueOrThrow(where: { discoveredService in
119+
// ignore characteristics from services we're not interested in.
120+
guard discoveredService.uuid == service.uuid else { return false }
121+
// nil identifiers means we want to discover anything we can
122+
guard let identifiers = characteristicUUIDs else { return true }
123+
// Only progress if the discovered service contains all the characteristics we are looking for.
124+
let neededUUIDs = Set(identifiers)
125+
let foundUUIDs = Set((discoveredService.characteristics ?? []).map(\.uuid))
126+
let allFound = foundUUIDs.isSuperset(of: neededUUIDs)
127+
return allFound
128+
})
129+
.map({ $0.characteristics ?? [] })
130+
.handleEvents(receiveSubscription: { [_discoverCharacteristics] _ in
131+
_discoverCharacteristics(characteristicUUIDs, service)
132+
})
133+
.shareCurrentValue()
134+
.eraseToAnyPublisher()
73135
}
74136

75-
public func readValue(for characteristic: CBCharacteristic) -> AnyPublisher<CBCharacteristic, Error> {
76-
_readValueForCharacteristic(characteristic)
137+
public func readValue(for characteristic: CBCharacteristic) -> AnyPublisher<Data?, Error> {
138+
delegate
139+
.didUpdateValueForCharacteristic
140+
.filterFirstValueOrThrow(where: {
141+
$0.uuid == characteristic.uuid
142+
})
143+
.map(\.value)
144+
.handleEvents(receiveSubscription: { [_readValueForCharacteristic] _ in
145+
_readValueForCharacteristic(characteristic)
146+
})
147+
.shareCurrentValue()
148+
.eraseToAnyPublisher()
77149
}
78150

79151
public func maximumWriteValueLength(for writeType: CBCharacteristicWriteType) -> Int {
80152
_maximumWriteValueLength(writeType)
81153
}
82154

83-
public func writeValue(_ value: Data, for characteristic: CBCharacteristic, type writeType: CBCharacteristicWriteType) -> AnyPublisher<CBCharacteristic, Error> {
84-
_writeValueForCharacteristic(value, characteristic, writeType)
155+
public func writeValue(_ value: Data, for characteristic: CBCharacteristic, type writeType: CBCharacteristicWriteType) -> AnyPublisher<Void, Error> {
156+
if writeType == .withoutResponse {
157+
return writeValueWithoutResponse(value, for: characteristic)
158+
} else {
159+
return writeValueWithResponse(value, for: characteristic)
160+
}
85161
}
86162

87-
public func setNotifyValue(_ enabled: Bool, for characteristic: CBCharacteristic) -> AnyPublisher<CBCharacteristic, Error> {
88-
_setNotifyValue(enabled, characteristic)
163+
private func writeValueWithoutResponse(_ value: Data, for characteristic: CBCharacteristic) -> AnyPublisher<Void, Error> {
164+
if characteristic.properties.contains(.writeWithoutResponse) {
165+
// Return an empty publisher here, since we never expect to receive a response.
166+
return Empty()
167+
.handleEvents(receiveSubscription: { [_writeValueForCharacteristic] _ in
168+
_writeValueForCharacteristic(value, characteristic, .withoutResponse)
169+
})
170+
.eraseToAnyPublisher()
171+
} else {
172+
// a response-less write against a characteristic that doesn't support it is silently ignored
173+
// by core bluetooth and never sends to the peripheral, so surface that case with an error here instead.
174+
return Fail(
175+
error: NSError(
176+
domain: CBATTErrorDomain,
177+
code: CBATTError.writeNotPermitted.rawValue,
178+
userInfo: [
179+
NSLocalizedDescriptionKey: "Writing without response is not permitted."
180+
]
181+
)
182+
)
183+
.eraseToAnyPublisher()
184+
}
185+
}
186+
187+
private func writeValueWithResponse(_ value: Data, for characteristic: CBCharacteristic) -> AnyPublisher<Void, Error> {
188+
delegate
189+
.didWriteValueForCharacteristic
190+
.filterFirstValueOrThrow(where: {
191+
$0.uuid == characteristic.uuid
192+
})
193+
.map { _ in }
194+
.handleEvents(receiveSubscription: { [_writeValueForCharacteristic] _ in
195+
_writeValueForCharacteristic(value, characteristic, .withResponse)
196+
})
197+
.shareCurrentValue()
198+
.eraseToAnyPublisher()
199+
}
200+
201+
public func setNotifyValue(_ enabled: Bool, for characteristic: CBCharacteristic) -> AnyPublisher<Void, Error> {
202+
delegate
203+
.didUpdateNotificationState
204+
.filterFirstValueOrThrow(where: {
205+
$0.uuid == characteristic.uuid
206+
})
207+
.map { _ in }
208+
.handleEvents(receiveSubscription: { [_setNotifyValue] _ in
209+
_setNotifyValue(enabled, characteristic)
210+
})
211+
.shareCurrentValue()
212+
.eraseToAnyPublisher()
89213
}
90214

91215
public func discoverDescriptors(for characteristic: CBCharacteristic) -> AnyPublisher<[CBDescriptor]?, Error> {
92-
_discoverDescriptors(characteristic)
216+
delegate
217+
.didDiscoverDescriptorsForCharacteristic
218+
.filterFirstValueOrThrow(where: {
219+
$0.uuid == characteristic.uuid
220+
})
221+
.map(\.descriptors)
222+
.handleEvents(receiveSubscription: { [_discoverDescriptors] _ in
223+
_discoverDescriptors(characteristic)
224+
})
225+
.shareCurrentValue()
226+
.eraseToAnyPublisher()
93227
}
94228

95-
public func readValue(for descriptor: CBDescriptor) -> AnyPublisher<CBDescriptor, Error> {
96-
_readValueForDescriptor(descriptor)
229+
public func readValue(for descriptor: CBDescriptor) -> AnyPublisher<Any?, Error> {
230+
delegate
231+
.didUpdateValueForDescriptor
232+
.filterFirstValueOrThrow(where: {
233+
$0.uuid == descriptor.uuid
234+
})
235+
.map(\.value)
236+
.handleEvents(receiveSubscription: { [_readValueForDescriptor] _ in
237+
_readValueForDescriptor(descriptor)
238+
})
239+
.shareCurrentValue()
240+
.eraseToAnyPublisher()
97241
}
98242

99-
public func writeValue(_ value: Data, for descriptor: CBDescriptor) -> AnyPublisher<CBDescriptor, Error> {
100-
_writeValueForDescriptor(value, descriptor)
243+
public func writeValue(_ value: Data, for descriptor: CBDescriptor) -> AnyPublisher<Void, Error> {
244+
delegate
245+
.didWriteValueForDescriptor
246+
.filterFirstValueOrThrow(where: {
247+
$0.uuid == descriptor.uuid
248+
})
249+
.map { _ in }
250+
.handleEvents(receiveSubscription: { [_writeValueForDescriptor] _ in
251+
_writeValueForDescriptor(value, descriptor)
252+
})
253+
.shareCurrentValue()
254+
.eraseToAnyPublisher()
101255
}
102256

103257
public func openL2CAPChannel(_ psm: CBL2CAPPSM) -> AnyPublisher<L2CAPChannel, Error> {
104-
_openL2CAPChannel(psm)
258+
delegate
259+
.didOpenChannel
260+
.filterFirstValueOrThrow(where: { channel, error in
261+
return channel?.psm == psm || error != nil
262+
})
263+
// we won't get here unless channel is not nil, so we can safely force-unwrap
264+
.map { $0! }
265+
.handleEvents(receiveSubscription: { [_openL2CAPChannel] _ in
266+
_openL2CAPChannel(psm)
267+
})
268+
.shareCurrentValue()
269+
.eraseToAnyPublisher()
105270
}
271+
}
106272

107-
public func listenForUpdates(on characteristic: CBCharacteristic) -> AnyPublisher<CBCharacteristic, Error> {
108-
_listenForUpdatesToCharacteristic(characteristic)
273+
extension Peripheral {
274+
public class Delegate: NSObject {
275+
let nameUpdates: PassthroughSubject<String?, Never> = .init()
276+
let didInvalidateServices: PassthroughSubject<[CBService], Never> = .init()
277+
let didReadRSSI: PassthroughSubject<Result<Double, Error>, Never> = .init()
278+
let didDiscoverServices: PassthroughSubject<([CBService], Error?), Never> = .init()
279+
let didDiscoverIncludedServices: PassthroughSubject<(CBService, Error?), Never> = .init()
280+
let didDiscoverCharacteristics: PassthroughSubject<(CBService, Error?), Never> = .init()
281+
let didUpdateValueForCharacteristic: PassthroughSubject<(CBCharacteristic, Error?), Never> = .init()
282+
let didWriteValueForCharacteristic: PassthroughSubject<(CBCharacteristic, Error?), Never> = .init()
283+
let didUpdateNotificationState: PassthroughSubject<(CBCharacteristic, Error?), Never> = .init()
284+
let didDiscoverDescriptorsForCharacteristic: PassthroughSubject<(CBCharacteristic, Error?), Never> = .init()
285+
let didUpdateValueForDescriptor: PassthroughSubject<(CBDescriptor, Error?), Never> = .init()
286+
let didWriteValueForDescriptor: PassthroughSubject<(CBDescriptor, Error?), Never> = .init()
287+
let isReadyToSendWriteWithoutResponse: PassthroughSubject<Void, Never> = .init()
288+
let didOpenChannel: PassthroughSubject<(L2CAPChannel?, Error?), Never> = .init()
109289
}
110290
}
111291

0 commit comments

Comments
 (0)