IOS-CoreBluetooth-Mock/CoreBluetoothMock/CBMCentralManagerMock.swift

1608 lines
70 KiB
Swift

/*
* Copyright (c) 2020, Nordic Semiconductor
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this
* list of conditions and the following disclaimer in the documentation and/or
* other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may
* be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
import CoreBluetooth
/// Mock implementation of the ``CBMCentralManager``.
///
/// This implementation will interact only with mock peripherals created using
/// ``CBMPeripheralSpec/simulatePeripheral(identifier:proximity:)``.
open class CBMCentralManagerMock: CBMCentralManager {
/// Mock RSSI deviation.
///
/// Returned RSSI values will be in range
/// `(base RSSI - deviation)...(base RSSI + deviation)`.
fileprivate static let rssiDeviation = 15 // dBm
/// A list of all mock managers instantiated by user.
private static var managers: [WeakRef<CBMCentralManagerMock>] = []
/// A list of peripherals known to the system.
private static var peripherals: [CBMPeripheralSpec] = [] {
didSet {
stopAdvertising()
initializeAdvertising()
}
}
/// A list of ``CBMPeripheral``s for SwiftUI Previews only.
///
/// Registered items can be accessed using any ``CBMCentralManagerMock``.
private static var previewPeripherals: Set<CBMPeripheralPreview> = Set()
/// A map of all current advertisements of all simulated peripherals.
private static var advertisementTimers: [CBMAdvertisementConfig : Timer] = [:]
/// A mutex queue for managing managers.
private static let mutex: DispatchQueue = DispatchQueue(label: "Mutex")
/// The value of current authorization status for using Bluetooth.
///
/// As `CBManagerAuthorization` was added in iOS 13, the raw value is kept.
internal private(set) static var bluetoothAuthorization: Int? {
didSet {
notifyManagers()
}
}
/// The global state of the Bluetooth adapter on the device.
fileprivate private(set) static var managerState: CBMManagerState = .poweredOff {
didSet {
notifyManagers()
}
}
private static func notifyManagers() {
// For all existing managers...
let existingManagers = mutex.sync {
managers.compactMap { $0.ref }
}
existingManagers.forEach { manager in
// ...stop scanning if state changed to any other state
// than `.poweredOn`. Also, forget all peripherals.
if manager.state != .poweredOn {
manager._isScanning = false
manager.scanFilter = nil
manager.scanOptions = nil
manager.peripherals.values.forEach { $0.closeManager() }
manager.peripherals.removeAll()
}
// ...and notify delegate.
manager.queue.async {
manager.delegate?.centralManagerDidUpdateState(manager)
}
}
// Compact the list, if any of managers were disposed.
mutex.sync {
managers.removeAll { $0.ref == nil }
}
}
/// Restarts advertising for all mock peripherals.
///
/// The advertisement delays will be counted from the moment they are started.
private static func initializeAdvertising() {
peripherals
.compactMap { peripheral in peripheral.advertisement?.map { config in (peripheral, config) } }
.flatMap { $0 }
.forEach { peripheral, config in
startAdvertising(config, for: peripheral)
}
}
/// Stops all advertising.
private static func stopAdvertising() {
advertisementTimers.forEach { $0.value.invalidate() }
advertisementTimers.removeAll()
}
/// Starts the given advertisement.
///
/// The advertisement delay will be counted from moment it is started.
/// - Parameters:
/// - config: Advertisement configuration to start.
/// - mock: The advertising mock peripheral.
private static func startAdvertising(_ config: CBMAdvertisementConfig, for mock: CBMPeripheralSpec) {
// A valid advertising config is a single time advertisement (delay > 0),
// or a periodic one (interval > 0) (or both - delayed periodic advertisement).
// Not to be mistaken with "Periodic Advertisement" from Advertising Extension.
guard config.delay > 0 || config.interval > 0 else {
return
}
// Timer works only on queues with a active run loop.
DispatchQueue.main.async {
// If the first advertising is to be delayed, create a
// temporary timer that will call the initial data.
if config.delay > 0 {
advertisementTimers[config] = Timer.scheduledTimer(
timeInterval: config.delay,
target: self,
selector: #selector(self.schedule(timer:)),
userInfo: (mock, config),
repeats: false)
} else {
advertisementTimers[config] = Timer.scheduledTimer(
timeInterval: config.interval,
target: self,
selector: #selector(self.notify(timer:)),
userInfo: (mock, config),
repeats: true)
}
}
}
/// Stops all advertising of the given peripheral.
/// - Parameter mock: The mock peripheral that changed advertising set.
private static func stopAdvertising(of mock: CBMPeripheralSpec) {
mock.advertisement?.forEach { config in
advertisementTimers
.removeValue(forKey: config)?
.invalidate()
}
}
/// This timer is fired when the initial delay has passed and the device starts
/// advertising with a advertisement config.
///
/// The peripheral specification and advertising configuration is set as `userInfo`.
/// - Parameter timer: The timer that is fired.
@objc private static func schedule(timer: Timer) {
guard let (mock, config) = timer.userInfo as? (CBMPeripheralSpec, CBMAdvertisementConfig) else {
return
}
notify(config, for: mock)
if config.interval > 0 {
advertisementTimers[config] = Timer.scheduledTimer(
timeInterval: config.interval,
target: self,
selector: #selector(self.notify(timer:)),
userInfo: (mock, config),
repeats: true)
}
}
/// This is a Timer callback, that's called to emulate scanning for Bluetooth LE
/// devices.
///
/// The peripheral specification and advertising configuration is set as `userInfo`.
/// - Parameter timer: The timer that is fired.
@objc private static func notify(timer: Timer) {
guard let (mock, config) = timer.userInfo as? (CBMPeripheralSpec, CBMAdvertisementConfig) else {
return
}
notify(config, for: mock)
}
/// This is a Timer callback, that's called to emulate scanning for Bluetooth LE
/// devices.
///
/// The scanned peripheral is set as `userInfo`.
/// - Parameters:
/// - config: Advertisement configuration to start.
/// - mock: The advertising mock peripheral.
private static func notify(_ config: CBMAdvertisementConfig, for mock: CBMPeripheralSpec) {
// If a peripheral is out of range, the packet gets missed.
guard mock.proximity != .outOfRange else {
return
}
// If the device is connected and does not advetise in that state, skip.
guard !mock.isConnected || config.isAdvertisingWhenConnected else {
return
}
let services = config.data[CBMAdvertisementDataServiceUUIDsKey] as? [CBMUUID]
// Notify managers...
let existingManagers = CBMCentralManagerMock.mutex.sync {
managers.compactMap { $0.ref }
}
existingManagers
// that are scanning with no UUID filter, empty filter, or with at least one service in common.
.filter { manager in
manager.isScanning && (
manager.scanFilter == nil || manager.scanFilter!.isEmpty ||
services?.contains(where: manager.scanFilter!.contains) ?? false
)
}
// For each scanning manager do the following:
.forEach { manager in
// The device has been scanned and cached.
mock.isKnown = true
// Get or create local peripheral instance.
if manager.peripherals[mock.identifier] == nil {
manager.peripherals[mock.identifier] = CBMPeripheralMock(basedOn: mock, by: manager)
}
let peripheral = manager.peripherals[mock.identifier]!
// If the Allow Duplicates flag was not set and the device was already reported,
// don't report it for th second time
let allowDuplicates = manager.scanOptions?[CBMCentralManagerScanOptionAllowDuplicatesKey] as? NSNumber ?? false as NSNumber
if !peripheral.wasScanned || allowDuplicates.boolValue {
// Remember the scanned name from the last advertising packet.
peripheral.lastAdvertisedName = config.data[CBAdvertisementDataLocalNameKey] as? String ?? peripheral.lastAdvertisedName
// Emulate RSSI based on proximity. Apply some deviation.
let rssi = mock.proximity.RSSI
let delta = CBMCentralManagerMock.rssiDeviation
let deviation = Int.random(in: -delta...delta)
manager.delegate?.centralManager(manager, didDiscover: peripheral,
advertisementData: config.data,
rssi: (rssi + deviation) as NSNumber)
// The first scan result is returned without a name.
// This flag must then be called after it has been reported.
// Setting this flag will cause the advertising name to be
// returned from CBPeripheral.name.
peripheral.wasScanned = true
}
}
// When an connectable advertising packet was received from a device check if there
// are any pending connections.
let isConnectable = config.data[CBMAdvertisementDataIsConnectable] as? NSNumber ?? false as NSNumber
if isConnectable.boolValue {
peripheralBecameAvailable(mock)
}
}
/// Whether the app is currently authorized to use Bluetooth.
///
/// If `simulateAuthorization(:)` was not called it is assumed that the
/// authorization was granted. However, in this case `CBMCentralManager.authorization`
/// will return the value returned by the native API.
private static var isAuthorized: Bool {
return bluetoothAuthorization == nil || bluetoothAuthorization == 3 // CBManagerAuthorization.allowedAlways
}
private var scanFilter: [CBMUUID]?
private var scanOptions: [String : Any]?
/// The dispatch queue used for all callbacks.
fileprivate let queue: DispatchQueue
/// A map of peripherals known to this central manager.
private var peripherals: [UUID : CBMPeripheralMock] = [:]
/// A flag set to true few milliseconds after the manager is created.
/// Some features, like the state or retrieving peripherals are not
/// available when manager hasn't been initialized yet.
private var initialized: Bool {
// This method returns true if the manager is added to
// the list of managers.
// Calling tearDownSimulation() will remove all managers
// from that list, making them uninitialized again.
CBMCentralManagerMock.mutex.sync {
CBMCentralManagerMock.managers.contains { $0.ref == self }
}
}
/// A flag set to true when the manager is scanning for mock Bluetooth LE devices.
private var _isScanning: Bool
// MARK: - Initializers
public init() {
self._isScanning = false
self.queue = DispatchQueue.main
super.init(true)
initialize()
}
public init(delegate: CBMCentralManagerDelegate?,
queue: DispatchQueue?) {
self._isScanning = false
self.queue = queue ?? DispatchQueue.main
super.init(true)
self.delegate = delegate
initialize()
}
@available(iOS 7.0, *)
public init(delegate: CBMCentralManagerDelegate?,
queue: DispatchQueue?,
options: [String : Any]?) {
self._isScanning = false
self.queue = queue ?? DispatchQueue.main
super.init(true)
self.delegate = delegate
if let options = options,
let identifierKey = options[CBMCentralManagerOptionRestoreIdentifierKey] as? String,
let dict = CBMCentralManagerMock.simulateStateRestoration?(identifierKey) {
var state: [String : Any] = [:]
if let peripheralKeys = dict[CBMCentralManagerRestoredStatePeripheralsKey] {
state[CBMCentralManagerRestoredStatePeripheralsKey] = peripheralKeys
}
if let scanServiceKey = dict[CBMCentralManagerRestoredStateScanServicesKey] {
state[CBMCentralManagerRestoredStateScanServicesKey] = scanServiceKey
}
if let scanOptions = dict[CBMCentralManagerRestoredStateScanOptionsKey] {
state[CBMCentralManagerRestoredStateScanOptionsKey] = scanOptions
}
delegate?.centralManager(self, willRestoreState: state)
}
initialize()
}
private func initialize() {
if CBMCentralManagerMock.managerState == .poweredOn &&
CBMCentralManagerMock.peripherals.isEmpty {
NSLog("Warning: No simulated peripherals. " +
"Call simulatePeripherals(:) before creating central manager")
}
queue.async { [weak self] in
if let self = self {
CBMCentralManagerMock.mutex.sync {
CBMCentralManagerMock.managers.append(WeakRef(self))
}
self.delegate?.centralManagerDidUpdateState(self)
}
}
}
// MARK: - Central manager simulation methods
/// This method may be used to register a list ot ``CBMPeripheralPreview`` should they be used in Swift UI Previews.
///
/// Registered peripherals can be connected, retrieved, and respond to basic requests
/// - Parameter peripherals: The list of peripherals intended for Swift UI purposes.
internal static func registerForPreviews(_ peripheral: CBMPeripheralPreview) {
previewPeripherals.insert(peripheral)
}
/// Removes all active central manager instances and peripherals from the
/// simulation, resetting it to the initial state.
///
/// Use this to tear down your mocks between tests, e.g. in `tearDownWithError()`.
/// All manager delegates will receive a ``CBMManagerState/unknown`` state update.
public static func tearDownSimulation() {
stopAdvertising()
// Set the state of all currently existing cenral manager instances to
// .unknown, which will make them invalid.
managerState = .unknown
// Remove all central manager instances.
mutex.sync {
managers.removeAll()
}
// Set the manager state to powered Off.
managerState = .poweredOff
peripherals.removeAll()
}
/// Simulates the current authorization state of a Core Bluetooth manager.
///
/// When set to `nil` (default), the native value is returned.
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public static func simulateAuthorization(_ authorization: CBMManagerAuthorization) {
bluetoothAuthorization = authorization.rawValue
}
/// This simulation method is called when a mock central manager was
/// created with an option to restore the state
/// (``CBMCentralManagerOptionRestoreIdentifierKey``).
///
/// The returned map, if not `nil`, will be passed to
/// ``CBMCentralManagerDelegate/centralManager(_:willRestoreState:)-4zyhg`` before creation.
/// - SeeAlso: ``CBMCentralManagerRestoredStatePeripheralsKey``
/// - SeeAlso: ``CBMCentralManagerRestoredStateScanServicesKey``
/// - SeeAlso: ``CBMCentralManagerRestoredStateScanOptionsKey``
public static var simulateStateRestoration: ((_ identifierKey: String) -> [String : Any]?)?
#if !os(macOS)
/// Returns a boolean value representing the support for the provided features.
///
/// This method will be called when ``CBMCentralManager/supports(_:)`` method is called.
@available(iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public static var simulateFeaturesSupport: ((_ features: CBMCentralManager.Feature) -> Bool)?
#endif
/// Sets the initial state of the Bluetooth central manager.
///
/// This method should only be called ones, before any central manager
/// is created. By default, the initial state is ``CBMManagerState/poweredOff``.
/// - Parameter state: The initial state of the central manager.
public static func simulateInitialState(_ state: CBMManagerState) {
managerState = state
}
/// This method sets a list of simulated peripherals.
///
/// Peripherals added using this method will be available for scanning
/// and connecting, depending on their proximity. Use peripheral's
/// ``CBMPeripheralSpec/simulateProximityChange(_:)`` to modify proximity.
///
/// This method may only be called before any central manager was created
/// or when Bluetooth state is ``CBMManagerState/poweredOff``. Existing list of peripherals
/// will be overridden.
/// - Parameter peripherals: Peripherals specifications.
public static func simulatePeripherals(_ peripherals: [CBMPeripheralSpec]) {
guard managers.isEmpty || managerState == .poweredOff else {
NSLog("Warning: Peripherals can not be added while the simulation is running. " +
"Add peripherals before getting any central manager instance, " +
"or when manager is powered off.")
return
}
CBMCentralManagerMock.peripherals = peripherals
}
/// Simulates turning the Bluetooth adapter on.
public static func simulatePowerOn() {
guard managerState != .poweredOn else {
return
}
managerState = .poweredOn
}
/// Simulate turning the Bluetooth adapter off.
public static func simulatePowerOff() {
guard managerState != .poweredOff else {
return
}
managerState = .poweredOff
}
// MARK: - Peripheral simulation methods
/// Simulates a situation when the given peripheral was moved closer
/// or away from the phone.
///
/// If the proximity is changed to ``CBMProximity/outOfRange``, the peripheral will
/// be disconnected and will not appear on scan results.
/// - Parameter peripheral: The peripheral that was repositioned.
/// - Parameter proximity: The new peripheral proximity.
internal static func proximity(of peripheral: CBMPeripheralSpec,
didChangeTo proximity: CBMProximity) {
guard peripheral.proximity != proximity else {
return
}
// Is the peripheral simulated?
guard peripherals.contains(peripheral) else {
return
}
peripheral.proximity = proximity
if proximity == .outOfRange {
self.peripheral(peripheral,
didDisconnectWithError: CBMError(.connectionTimeout))
} // else {
// If a device got in range an advertising packet will be received
// at some point. Any pending connections will succeed at that time.
//}
}
/// Simulates a situation when the device changes its services.
/// - Parameters:
/// - peripheral: The peripheral that changed services.
/// - newName: New device name.
/// - newServices: New list of device services.
internal static func peripheral(_ peripheral: CBMPeripheralSpec,
didUpdateName newName: String?,
andServices newServices: [CBMServiceMock]) {
// Is the peripheral simulated?
guard peripherals.contains(peripheral) else {
return
}
peripheral.services = newServices
// If there are no connected devices, we're done.
guard peripheral.virtualConnections > 0 else {
return
}
let existingManagers = CBMCentralManagerMock.mutex.sync {
managers.compactMap { $0.ref }
}
existingManagers.forEach { manager in
manager.peripherals[peripheral.identifier]?
.notifyServicesChanged()
}
// Notify that the name has changed.
if peripheral.name != newName {
peripheral.name = newName
existingManagers.forEach { manager in
// TODO: This needs to be verified.
// Should a local peripheral copy be created if no such?
// Are all central managers notified about any device
// changing name?
manager.peripherals[peripheral.identifier]?
.notifyNameChanged()
}
}
}
/// Simulates a notification sent from the peripheral.
///
/// All central managers that have enabled notifications on it
/// will receive ``CBMPeripheralDelegate/peripheral(_:didUpdateValueFor:error:)-62302``.
/// - Parameter characteristic: The characteristic from which
/// notification is to be sent.
internal static func peripheral(_ peripheral: CBMPeripheralSpec,
didUpdateValueFor characteristic: CBMCharacteristicMock) {
// Is the peripheral simulated?
guard peripherals.contains(peripheral) else {
return
}
guard peripheral.virtualConnections > 0 else {
return
}
let existingManagers = CBMCentralManagerMock.mutex.sync {
managers.compactMap { $0.ref }
}
existingManagers.forEach { manager in
manager.peripherals[peripheral.identifier]?
.notifyValueChanged(for: characteristic)
}
}
/// Simulates a change in advertising packets for the given peripheral.
///
/// The full advertising set is replaced with a new one and all timers are restarted.
/// - Parameters:
/// - peripheral: The peripheral that changed advertising.
/// - advertisement: The new advertising set.
internal static func peripheral(_ peripheral: CBMPeripheralSpec,
didChangeAdvertisement advertisement: [CBMAdvertisementConfig]?) {
// Stop current advertising of the given device.
stopAdvertising(of: peripheral)
// Set new advertising set.
peripheral.advertisement = advertisement
peripheral.advertisement?.forEach { config in
startAdvertising(config, for: peripheral)
}
}
/// This method simulates a new virtual connection to the given
/// peripheral, as if some other application connected to it.
///
/// Central managers will not be notified about the state change unless
/// they registered for connection events using
/// ``CBMCentralManager/registerForConnectionEvents(options:)``.
/// Even without registering (which is available since iOS 13), they
/// can retrieve the connected peripheral using
/// ``CBMCentralManager/retrieveConnectedPeripherals(withServices:)``.
///
/// The peripheral does not need to be registered before.
/// - Parameter peripheral: The peripheral that has connected.
internal static func peripheralDidConnect(_ peripheral: CBMPeripheralSpec) {
// Is the peripheral simulated?
guard peripherals.contains(peripheral) else {
return
}
peripheral.virtualConnections += 1
// TODO: notify a user registered for connection events
}
/// Method called when a peripheral becomes available (in range).
/// If there is a pending connection request, it will connect.
/// - Parameter peripheral: The peripheral that came in range.
internal static func peripheralBecameAvailable(_ peripheral: CBMPeripheralSpec) {
let existingManagers = CBMCentralManagerMock.mutex.sync {
managers.compactMap { $0.ref }
}
existingManagers.forEach { manager in
if let target = manager.peripherals[peripheral.identifier],
target.state == .connecting {
target.connect() { result in
switch result {
case .success:
manager.delegate?.centralManager(manager, didConnect: target)
case .failure(let error):
manager.delegate?.centralManager(manager, didFailToConnect: target,
error: error)
}
}
}
}
}
/// Simulates the peripheral to disconnect from the device.
///
/// All connected mock central managers will receive
/// ``CBMCentralManagerDelegate/centralManager(_:didDisconnectPeripheral:error:)-1lv48`` callback.
/// - Parameter peripheral: The peripheral to disconnect.
/// - Parameter error: The disconnection reason. Use ``CBMError`` or ``CBMATTError`` errors.
internal static func peripheral(_ peripheral: CBMPeripheralSpec,
didDisconnectWithError error: Error = CBError(.peripheralDisconnected)) {
// Is the device connected at all?
guard peripheral.isConnected else {
return
}
// Is the peripheral simulated?
guard peripherals.contains(peripheral) else {
return
}
// The device has disconnected, so it can start advertising
// immediately.
peripheral.virtualConnections = 0
// Notify all central managers.
let existingManagers = CBMCentralManagerMock.mutex.sync {
managers.compactMap { $0.ref }
}
existingManagers.forEach { manager in
if let target = manager.peripherals[peripheral.identifier],
target.state == .connected {
target.disconnected(withError: error) { error in
manager.delegate?.centralManager(manager,
didDisconnectPeripheral: target,
error: error)
}
}
}
// TODO: notify a user registered for connection events
}
// MARK: - CBCentralManager mock methods
open override var state: CBMManagerState {
guard initialized else {
return .unknown
}
guard CBMCentralManagerMock.isAuthorized else {
return .unauthorized
}
return CBMCentralManagerMock.managerState
}
open override var isScanning: Bool {
return _isScanning
}
@available(iOS, introduced: 13.0, deprecated: 13.1)
@available(macOS, introduced: 10.15)
@available(tvOS, introduced: 13.0, deprecated: 13.1)
@available(watchOS, introduced: 6.0, deprecated: 6.1)
open override var authorization: CBMManagerAuthorization {
if let rawValue = CBMCentralManagerMock.bluetoothAuthorization,
let authotization = CBMManagerAuthorization(rawValue: rawValue) {
return authotization
} else {
// If `simulateAuthorization(:)` was not called, .allowedAlways is assumed.
return .allowedAlways
}
}
@available(iOS 13.1, macOS 10.15, tvOS 13.1, watchOS 6.1, *)
open override class var authorization: CBMManagerAuthorization {
if let rawValue = CBMCentralManagerMock.bluetoothAuthorization,
let authorization = CBMManagerAuthorization(rawValue: rawValue) {
return authorization
} else {
// If `simulateAuthorization(:)` was not called, .allowedAlways is assumed.
return .allowedAlways
}
}
open override func scanForPeripherals(withServices serviceUUIDs: [CBMUUID]?,
options: [String : Any]? = nil) {
// Central manager must be in powered on state.
guard ensurePoweredOn() else { return }
_isScanning = true
scanFilter = serviceUUIDs
scanOptions = options
}
open override func stopScan() {
// Central manager must be in powered on state.
guard ensurePoweredOn() else { return }
_isScanning = false
scanFilter = nil
scanOptions = nil
peripherals.values.forEach { $0.wasScanned = false }
}
open override func connect(_ peripheral: CBMPeripheral, options: [String : Any]? = nil) {
// Handle the Preview peripheral.
if let peripheral = peripheral as? CBMPeripheralPreview {
peripheral.state = .connected
delegate?.centralManager(self, didConnect: peripheral)
return
}
// Central manager must be in powered on state.
guard ensurePoweredOn() else { return }
if let o = options, !o.isEmpty {
NSLog("Warning: Connection options are not supported in mock central manager")
}
// Ignore peripherals that are not mocks.
guard let mock = peripheral as? CBMPeripheralMock else {
return
}
// The peripheral must come from this central manager. Ignore other.
// To connect a peripheral obtained using another central manager
// use `retrievePeripherals(withIdentifiers:)` or
// `retrieveConnectedPeripherals(withServices:)`.
guard peripherals.values.contains(mock) else {
return
}
// Connection is pending.
mock.state = .connecting
// If the device is already connected, there is no need to waiting for
// advertising packet.
if mock.isAlreadyConnected {
mock.connect() { _ in
self.delegate?.centralManager(self, didConnect: mock)
}
}
}
open override func cancelPeripheralConnection(_ peripheral: CBMPeripheral) {
// Handle the Preview peripheral.
if let peripheral = peripheral as? CBMPeripheralPreview {
peripheral.state = .disconnected
delegate?.centralManager(self, didDisconnectPeripheral: peripheral, error: nil)
return
}
// Central manager must be in powered on state.
guard ensurePoweredOn() else { return }
// Ignore peripherals that are not mocks.
guard let mock = peripheral as? CBMPeripheralMock else {
return
}
// It is not possible to cancel connection of a peripheral obtained
// from another central manager.
guard peripherals.values.contains(mock) else {
return
}
mock.disconnect() {
self.delegate?.centralManager(self, didDisconnectPeripheral: mock,
error: nil)
}
}
open override func retrievePeripherals(withIdentifiers identifiers: [UUID]) -> [CBMPeripheral] {
// Check if any Preview peripheral matches the identifier.
let previewPeripherals = Self.previewPeripherals
.filter{ identifiers.contains($0.identifier) }
if !previewPeripherals.isEmpty {
return Array(previewPeripherals)
}
// Starting from iOS 13, this method returns peripherals only in ON state.
guard ensurePoweredOn() else { return [] }
// Also, look for them among other managers, and copy them to the local
// manager.
let missingIdentifiers = identifiers.filter { peripherals[$0] == nil }
let existingManagers = CBMCentralManagerMock.mutex.sync {
CBMCentralManagerMock.managers.compactMap { $0.ref }
}
let peripheralsKnownByOtherManagers = missingIdentifiers
.flatMap { identifier in
existingManagers.compactMap { $0.peripherals[identifier] }
}
.map { CBMPeripheralMock(copy: $0, by: self) }
peripheralsKnownByOtherManagers.forEach {
peripherals[$0.identifier] = $0
}
// Peripherals that have not been scanned by any manager, but have been
// cached by the system and can be retrieved.
let stillMissingIdentifiers = identifiers.filter { peripherals[$0] == nil }
let peripheralsCached = CBMCentralManagerMock.peripherals
// Get only cached peripherals.
.filter { $0.isKnown }
// Search for those that are still missing.
.filter { stillMissingIdentifiers.contains($0.identifier) }
// Create a local copy.
.map { CBMPeripheralMock(basedOn: $0, by: self) }
peripheralsCached.forEach {
peripherals[$0.identifier] = $0
}
// Now, with updated peripherals, get those known to this central manager.
let localPeripherals = peripherals[identifiers]
// Return them in the same order as requested, some may be missing.
return localPeripherals
.sorted {
let firstIndex = identifiers.firstIndex(of: $0.identifier)!
let secondIndex = identifiers.firstIndex(of: $1.identifier)!
return firstIndex < secondIndex
}
}
open override func retrieveConnectedPeripherals(withServices serviceUUIDs: [CBMUUID]) -> [CBMPeripheral] {
// Check if there exist any Preview peripheral with at least one common service.
let previewPeripherals = Self.previewPeripherals
.filter { peripheral in
peripheral.services?.contains(where: { serviceUUIDs.contains($0.uuid) }) ?? false
}
if !previewPeripherals.isEmpty {
return Array(previewPeripherals)
}
// Starting from iOS 13, this method returns peripherals only in ON state.
guard ensurePoweredOn() else { return [] }
// Get the connected peripherals with at least one of the given services
// that are already known to this central manager.
let peripheralsConnectedByThisManager = peripherals[serviceUUIDs]
.filter { $0.state == .connected }
// Other central managers may know some connected peripherals that
// are not known to the local one.
let existingManagers = CBMCentralManagerMock.mutex.sync {
CBMCentralManagerMock.managers.compactMap { $0.ref }
}
let peripheralsConnectedByOtherManagers = existingManagers
// Look for connected peripherals known to other managers.
.flatMap {
$0.peripherals[serviceUUIDs]
.filter { $0.state == .connected }
}
// Search for ones that are not known to the local manager.
.filter { peripherals[$0.identifier] == nil }
// Create a local copy.
.map { CBMPeripheralMock(copy: $0, by: self) }
// Add those copies to the local manager.
peripheralsConnectedByOtherManagers.forEach {
peripherals[$0.identifier] = $0
}
let peripheralsConnectedByOtherApps = CBMCentralManagerMock.peripherals
.filter { $0.isConnected }
// Search for ones that are not known to the local manager.
.filter { peripherals[$0.identifier] == nil }
// And only those that match any of given service UUIDs.
.filter {
$0.services!.contains { service in
serviceUUIDs.contains(service.uuid)
}
}
// Create a local copy.
.map { CBMPeripheralMock(basedOn: $0, by: self) }
return peripheralsConnectedByThisManager
+ peripheralsConnectedByOtherManagers
+ peripheralsConnectedByOtherApps
}
#if !os(macOS)
@available(iOS 13.0, tvOS 13.0, watchOS 6.0, *)
open override func registerForConnectionEvents(options: [CBMConnectionEventMatchingOption : Any]? = nil) {
fatalError("Mock connection events are not implemented")
}
#endif
fileprivate func ensurePoweredOn() -> Bool {
guard state == .poweredOn else {
NSLog("[CoreBluetoothMock] API MISUSE: \(self) can only accept this command while in the powered on state")
return false
}
return true
}
}
// MARK: - CBPeripheralMock implementation
/// Mock implementation of the ``CBMPeripheral``.
///
/// This implementation will be used when creating peripherals by ``CBMCentralManagerMock``.
///
/// Unless required, this class should not be accessed directly, but rather by the common protocol ``CBMPeripheral``.
open class CBMPeripheralMock: CBMPeer, CBMPeripheral {
/// The parent central manager.
private let manager: CBMCentralManagerMock
/// The dispatch queue to call delegate methods on.
private var queue: DispatchQueue {
return manager.queue
}
private let mutex: DispatchQueue = DispatchQueue(label: "Mutex")
/// The mock peripheral with user-defined implementation.
private let mock: CBMPeripheralSpec
/// Size of the outgoing buffer. Only this many packets
/// can be written without response in a loop, without
/// waiting for ``CBMPeripheral/canSendWriteWithoutResponse``.
private let bufferSize = 20
/// The supervision timeout is a time after which a device realizes
/// that a connected peer has disconnected, had there been no signal
/// from it.
private let supervisionTimeout = 4.0
/// The current buffer size.
private var availableWriteWithoutResponseBuffer: Int
private var _canSendWriteWithoutResponse: Bool = false
/// A flag set to `true` when the device was scanned for the first time during
/// a single scan. This is to ensure that th result is not delivered twice unless
/// ``CBMCentralManagerScanOptionAllowDuplicatesKey`` flag is set.
fileprivate var wasScanned: Bool = false
fileprivate var lastAdvertisedName: String? = nil
fileprivate var isAlreadyConnected: Bool {
return mock.isConnected
}
open var delegate: CBMPeripheralDelegate?
open override var identifier: UUID {
return mock.identifier
}
open var name: String? {
return mock.wasConnected ? mock.name : lastAdvertisedName
}
@available(iOS 11.0, tvOS 11.0, watchOS 4.0, *)
open var canSendWriteWithoutResponse: Bool {
return _canSendWriteWithoutResponse
}
open private(set) var ancsAuthorized: Bool = false
open fileprivate(set) var state: CBMPeripheralState = .disconnected
open private(set) var services: [CBMService]? = nil
// MARK: Initializers
fileprivate init(basedOn mock: CBMPeripheralSpec,
by manager: CBMCentralManagerMock) {
self.mock = mock
self.manager = manager
self.availableWriteWithoutResponseBuffer = bufferSize
}
fileprivate init(copy: CBMPeripheralMock,
by manager: CBMCentralManagerMock) {
self.mock = copy.mock
self.manager = manager
self.availableWriteWithoutResponseBuffer = bufferSize
}
// MARK: Connection
fileprivate func connect(completion: @escaping (Result<Void, Error>) -> ()) {
// Ensure the device is disconnected.
guard state == .connecting else {
return
}
// Ensure the device is connectable and in range.
guard let delegate = mock.connectionDelegate,
let interval = mock.connectionInterval,
mock.proximity != .outOfRange else {
// There's no timeout on iOS. The device will connect when brought back
// into range. To cancel pending connection, call disconnect().
return
}
// If the device is already connected (using a different central manager),
// report success immediatly. The device already has the connection with central
// and will not be notified about another virtual client connection.
if isAlreadyConnected {
queue.async { [weak self] in
if let self = self, self.state == .connecting {
self.state = .connected
self._canSendWriteWithoutResponse = true
self.mock.wasConnected = true
self.mock.virtualConnections += 1
completion(.success(()))
}
}
return
}
// If the device wasn't connected emulate connection request.
let result = delegate.peripheralDidReceiveConnectionRequest(mock)
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
if let self = self, self.state == .connecting {
if case .success = result {
self.state = .connected
self._canSendWriteWithoutResponse = true
self.mock.wasConnected = true
self.mock.virtualConnections += 1
} else {
self.state = .disconnected
}
completion(result)
}
}
}
fileprivate func disconnect(completion: @escaping () -> ()) {
// Cancel pending connection.
guard state != .connecting else {
state = .disconnected
queue.async {
completion()
}
return
}
// Ensure the device is connectable and connected.
guard let interval = mock.connectionInterval,
state == .connected else {
return
}
if #available(iOS 9.0, *), case .connected = state {
state = .disconnecting
}
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
// `tearDownSimulation()` could have been called before this is called.
// See https://github.com/NordicSemiconductor/IOS-CoreBluetooth-Mock/issues/25
if let self = self, self.state == .disconnecting,
CBMCentralManagerMock.managerState == .poweredOn {
self.state = .disconnected
self.services = nil
self._canSendWriteWithoutResponse = false
self.mock.virtualConnections -= 1
self.mock.connectionDelegate?.peripheral(self.mock,
didDisconnect: nil)
completion()
}
}
}
fileprivate func disconnected(withError error: Error,
completion: @escaping (Error?) -> ()) {
// Ensure the device is connected.
guard var interval = mock.connectionInterval,
state == .connected else {
return
}
// If a device disconnected with a timeout, the central waits
// for the duration of supervision timeout before accepting
// disconnection.
if let error = error as? CBMError, error.code == .connectionTimeout {
interval = supervisionTimeout
}
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
if let self = self, CBMCentralManagerMock.managerState == .poweredOn {
self.state = .disconnected
self.services = nil
self._canSendWriteWithoutResponse = false
// If the disconnection happen without an error, the device
// must have been disconnected disconnected from central
// manager.
self.mock.virtualConnections = 0
self.mock.connectionDelegate?.peripheral(self.mock,
didDisconnect: error)
completion(error)
}
}
}
// MARK: Service modification
fileprivate func notifyNameChanged() {
guard state == .connected,
let interval = mock.connectionInterval else {
return
}
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
if let self = self, self.state == .connected {
self.delegate?.peripheralDidUpdateName(self)
}
}
}
fileprivate func notifyServicesChanged() {
guard state == .connected,
let oldServices = services,
let interval = mock.connectionInterval else {
return
}
// Keep only services that hadn't changed.
services = oldServices
.filter { service in
mock.services!.contains(where: {
$0.identifier == service.identifier
})
}
let invalidatedServices = oldServices.filter({ !services!.contains($0) })
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
if let self = self, self.state == .connected {
self.delegate?.peripheral(self, didModifyServices: invalidatedServices)
}
}
}
fileprivate func notifyValueChanged(for originalCharacteristic: CBMCharacteristicMock) {
guard state == .connected,
let interval = mock.connectionInterval,
let service = services?.first(where: {
$0.characteristics?.contains(where: {
$0.identifier == originalCharacteristic.identifier
}) ?? false
}),
let characteristic = service.characteristics?.first(where: {
$0.identifier == originalCharacteristic.identifier
}),
characteristic.isNotifying else {
return
}
let value = originalCharacteristic.value
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
if let self = self, self.state == .connected {
characteristic.value = value
self.delegate?.peripheral(self,
didUpdateValueFor: characteristic,
error: nil)
}
}
}
fileprivate func closeManager() {
state = .disconnected
services = nil
_canSendWriteWithoutResponse = false
mock.virtualConnections = 0
}
// MARK: Service discovery
open func discoverServices(_ serviceUUIDs: [CBMUUID]?) {
// Central manager must be in powered on state.
guard manager.ensurePoweredOn() else { return }
guard state == .connected,
let delegate = mock.connectionDelegate,
let interval = mock.connectionInterval,
let mockServices = mock.services else {
return
}
switch delegate.peripheral(mock,
didReceiveServiceDiscoveryRequest: serviceUUIDs) {
case .success:
services = services ?? []
let initialSize = services!.count
services = services! + mockServices
// Filter all device services that match given list (if set).
.filter { serviceUUIDs == nil || serviceUUIDs!.isEmpty || serviceUUIDs!.contains($0.uuid) }
// Filter those of them, that are not already in discovered services.
.filter { s in !services!
.contains { ds in s.identifier == ds.identifier }
}
// Copy the service info, without included services or characteristics.
.map { CBMService(shallowCopy: $0, for: self) }
let newServicesCount = services!.count - initialSize
// Service discovery may takes the more time, the more services
// are discovered.
let delay = interval * Double(newServicesCount)
queue.asyncAfter(deadline: .now() + delay) { [weak self] in
if let self = self, self.state == .connected {
self.delegate?.peripheral(self, didDiscoverServices: nil)
}
}
case .failure(let error):
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
if let self = self, self.state == .connected {
self.delegate?.peripheral(self, didDiscoverServices: error)
}
}
}
}
open func discoverIncludedServices(_ includedServiceUUIDs: [CBMUUID]?,
for service: CBMService) {
// Central manager must be in powered on state.
guard manager.ensurePoweredOn() else { return }
guard state == .connected,
let delegate = mock.connectionDelegate,
let interval = mock.connectionInterval,
let services = services, services.contains(service),
let mockServices = mock.services,
let mockService = mockServices.find(mockOf: service),
let mockIncludedServices = mockService.includedServices else {
return
}
switch delegate.peripheral(mock,
didReceiveIncludedServiceDiscoveryRequest: includedServiceUUIDs,
for: mockService) {
case .success:
service._includedServices = service._includedServices ?? []
let initialSize = service._includedServices!.count
service._includedServices = service._includedServices! +
mockIncludedServices
// Filter all included service that match given list (if set).
.filter { includedServiceUUIDs == nil || includedServiceUUIDs!.isEmpty || includedServiceUUIDs!.contains($0.uuid) }
// Filter those of them, that are not already in discovered services.
.filter { s in !service._includedServices!
.contains { ds in s.identifier == ds.identifier }
}
// Copy the service info, without included characteristics.
.map { CBMService(shallowCopy: $0, for: self) }
let newServicesCount = service._includedServices!.count - initialSize
// Service discovery may takes the more time, the more services
// are discovered.
let delay = interval * Double(newServicesCount)
queue.asyncAfter(deadline: .now() + delay) { [weak self] in
if let self = self, self.state == .connected {
self.delegate?.peripheral(self,
didDiscoverIncludedServicesFor: service,
error: nil)
}
}
case .failure(let error):
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
if let self = self, self.state == .connected {
self.delegate?.peripheral(self,
didDiscoverIncludedServicesFor: service,
error: error)
}
}
}
}
open func discoverCharacteristics(_ characteristicUUIDs: [CBMUUID]?,
for service: CBMService) {
// Central manager must be in powered on state.
guard manager.ensurePoweredOn() else { return }
guard state == .connected,
let delegate = mock.connectionDelegate,
let interval = mock.connectionInterval,
let services = services, services.contains(service),
let mockServices = mock.services,
let mockService = mockServices.find(mockOf: service),
let mockCharacteristics = mockService.characteristics else {
return
}
switch delegate.peripheral(mock,
didReceiveCharacteristicsDiscoveryRequest: characteristicUUIDs,
for: mockService) {
case .success:
service._characteristics = service._characteristics ?? []
let initialSize = service._characteristics!.count
service._characteristics = service._characteristics! +
mockCharacteristics
// Filter all service characteristics that match given list (if set).
.filter { characteristicUUIDs == nil || characteristicUUIDs!.isEmpty || characteristicUUIDs!.contains($0.uuid) }
// Filter those of them, that are not already in discovered characteristics.
.filter { c in !service._characteristics!
.contains { dc in c.identifier == dc.identifier }
}
// Copy the characteristic info, without included descriptors or value.
.map { CBMCharacteristic(shallowCopy: $0, in: service) }
let newCharacteristicsCount = service._characteristics!.count - initialSize
// Characteristics discovery may takes the more time, the more characteristics
// are discovered.
let delay = interval * Double(newCharacteristicsCount)
queue.asyncAfter(deadline: .now() + delay) { [weak self] in
if let self = self, self.state == .connected {
self.delegate?.peripheral(self,
didDiscoverCharacteristicsFor: service,
error: nil)
}
}
case .failure(let error):
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
if let self = self, self.state == .connected {
self.delegate?.peripheral(self,
didDiscoverCharacteristicsFor: service,
error: error)
}
}
}
}
open func discoverDescriptors(for characteristic: CBMCharacteristic) {
// Central manager must be in powered on state.
guard manager.ensurePoweredOn() else { return }
guard state == .connected,
let delegate = mock.connectionDelegate,
let interval = mock.connectionInterval,
let services = services,
let parentService = characteristic.optionalService, services.contains(parentService),
let mockServices = mock.services,
let mockCharacteristic = mockServices.find(mockOf: characteristic),
let mockDescriptors = mockCharacteristic.descriptors else {
return
}
switch delegate.peripheral(mock,
didReceiveDescriptorsDiscoveryRequestFor: mockCharacteristic) {
case .success:
characteristic._descriptors = characteristic._descriptors ?? []
let initialSize = characteristic._descriptors!.count
characteristic._descriptors = characteristic._descriptors! +
mockDescriptors
// Filter those of them, that are not already in discovered descriptors.
.filter { d in !characteristic._descriptors!
.contains { dd in d.identifier == dd.identifier }
}
// Copy the descriptors info, without the value.
.map { CBMDescriptor(shallowCopy: $0, in: characteristic) }
let newDescriptorsCount = characteristic._descriptors!.count - initialSize
// Descriptors discovery may takes the more time, the more descriptors
// are discovered.
let delay = interval * Double(newDescriptorsCount)
queue.asyncAfter(deadline: .now() + delay) { [weak self] in
if let self = self, self.state == .connected {
self.delegate?.peripheral(self,
didDiscoverDescriptorsFor: characteristic,
error: nil)
}
}
case .failure(let error):
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
if let self = self, self.state == .connected {
self.delegate?.peripheral(self,
didDiscoverDescriptorsFor: characteristic,
error: error)
}
}
}
}
// MARK: Read requests
open func readValue(for characteristic: CBMCharacteristic) {
// Central manager must be in powered on state.
guard manager.ensurePoweredOn() else { return }
guard state == .connected,
let delegate = mock.connectionDelegate,
let interval = mock.connectionInterval,
let services = services,
let service = characteristic.optionalService, services.contains(service),
let mockServices = mock.services,
let mockCharacteristic = mockServices.find(mockOf: characteristic) else {
return
}
switch delegate.peripheral(mock,
didReceiveReadRequestFor: mockCharacteristic) {
case .success(let data):
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
if let self = self, self.state == .connected {
characteristic.value = data
self.delegate?.peripheral(self,
didUpdateValueFor: characteristic,
error: nil)
}
}
case .failure(let error):
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
if let self = self, self.state == .connected {
self.delegate?.peripheral(self,
didUpdateValueFor: characteristic,
error: error)
}
}
}
}
open func readValue(for descriptor: CBMDescriptor) {
// Central manager must be in powered on state.
guard manager.ensurePoweredOn() else { return }
guard state == .connected,
let delegate = mock.connectionDelegate,
let interval = mock.connectionInterval,
let services = services,
let service = descriptor.optionalCharacteristic?.service, services.contains(service),
let mockServices = mock.services,
let mockDescriptor = mockServices.find(mockOf: descriptor) else {
return
}
switch delegate.peripheral(mock,
didReceiveReadRequestFor: mockDescriptor) {
case .success(let data):
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
if let self = self, self.state == .connected {
descriptor.value = data
self.delegate?.peripheral(self,
didUpdateValueFor: descriptor,
error: nil)
}
}
case .failure(let error):
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
if let self = self, self.state == .connected {
self.delegate?.peripheral(self,
didUpdateValueFor: descriptor,
error: error)
}
}
}
}
// MARK: Write requests
open func writeValue(_ data: Data,
for characteristic: CBMCharacteristic,
type: CBMCharacteristicWriteType) {
// Central manager must be in powered on state.
guard manager.ensurePoweredOn() else { return }
guard state == .connected,
let delegate = mock.connectionDelegate,
let interval = mock.connectionInterval,
let mtu = mock.mtu,
let services = services,
let service = characteristic.optionalService, services.contains(service),
let mockServices = mock.services,
let mockCharacteristic = mockServices.find(mockOf: characteristic) else {
return
}
if type == .withResponse {
switch delegate.peripheral(mock,
didReceiveWriteRequestFor: mockCharacteristic,
data: data) {
case .success:
let packetsCount = max(1, (data.count + mtu - 2) / (mtu - 3))
let delay = interval * Double(packetsCount)
queue.asyncAfter(deadline: .now() + delay) { [weak self] in
if let self = self, self.state == .connected {
self.delegate?.peripheral(self,
didWriteValueFor: characteristic,
error: nil)
}
}
case .failure(let error):
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
if let self = self, self.state == .connected {
self.delegate?.peripheral(self,
didWriteValueFor: characteristic,
error: error)
}
}
}
} else {
// Decrease buffer.
mutex.sync {
guard self.availableWriteWithoutResponseBuffer > 0 else {
return
}
self.availableWriteWithoutResponseBuffer -= 1
self._canSendWriteWithoutResponse = false
}
delegate.peripheral(mock,
didReceiveWriteCommandFor: mockCharacteristic,
data: data.subdata(in: 0..<min(mtu - 3, data.count)))
queue.async { [weak self] in
if let self = self, self.state == .connected {
// Increase buffer.
self.mutex.sync {
self.availableWriteWithoutResponseBuffer += 1
self._canSendWriteWithoutResponse = true
}
if #available(iOS 11.0, tvOS 11.0, watchOS 4.0, *) {
self.delegate?.peripheralIsReady(toSendWriteWithoutResponse: self)
}
}
}
}
}
open func writeValue(_ data: Data, for descriptor: CBMDescriptor) {
// Central manager must be in powered on state.
guard manager.ensurePoweredOn() else { return }
guard state == .connected,
let delegate = mock.connectionDelegate,
let interval = mock.connectionInterval,
let services = services,
let service = descriptor.optionalCharacteristic?.service, services.contains(service),
let mockServices = mock.services,
let mockDescriptor = mockServices.find(mockOf: descriptor) else {
return
}
switch delegate.peripheral(mock,
didReceiveWriteRequestFor: mockDescriptor,
data: data) {
case .success:
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
if let self = self, self.state == .connected {
self.delegate?.peripheral(self,
didWriteValueFor: descriptor,
error: nil)
}
}
case .failure(let error):
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
if let self = self, self.state == .connected {
self.delegate?.peripheral(self,
didWriteValueFor: descriptor,
error: error)
}
}
}
}
@available(iOS 9.0, *)
open func maximumWriteValueLength(for type: CBMCharacteristicWriteType) -> Int {
// Central manager must be in powered on state.
guard manager.ensurePoweredOn() else { return 0 }
guard state == .connected, let mtu = mock.mtu else {
return 0
}
return type == .withResponse ? 512 : mtu - 3
}
// MARK: Enabling notifications and indications
open func setNotifyValue(_ enabled: Bool,
for characteristic: CBMCharacteristic) {
// Central manager must be in powered on state.
guard manager.ensurePoweredOn() else { return }
guard state == .connected,
let delegate = mock.connectionDelegate,
let interval = mock.connectionInterval,
let services = services,
let service = characteristic.optionalService, services.contains(service),
let mockServices = mock.services,
let mockCharacteristic = mockServices.find(mockOf: characteristic) else {
return
}
guard enabled != characteristic.isNotifying else {
return
}
switch delegate.peripheral(mock,
didReceiveSetNotifyRequest: enabled,
for: mockCharacteristic) {
case .success:
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
if let self = self, self.state == .connected {
characteristic.isNotifying = enabled
self.delegate?.peripheral(self,
didUpdateNotificationStateFor: characteristic,
error: nil)
mockCharacteristic.isNotifying = enabled
self.mock.connectionDelegate?.peripheral(self.mock,
didUpdateNotificationStateFor: mockCharacteristic,
error: nil)
}
}
case .failure(let error):
queue.asyncAfter(deadline: .now() + interval) { [weak self] in
if let self = self, self.state == .connected {
self.delegate?.peripheral(self,
didUpdateNotificationStateFor: characteristic,
error: error)
self.mock.connectionDelegate?.peripheral(self.mock,
didUpdateNotificationStateFor: mockCharacteristic,
error: error)
}
}
}
}
// MARK: Other
open func readRSSI() {
// Central manager must be in powered on state.
guard manager.ensurePoweredOn() else { return }
queue.async { [weak self] in
if let self = self, self.state == .connected {
let rssi = self.mock.proximity.RSSI
let delta = CBMCentralManagerMock.rssiDeviation
let deviation = Int.random(in: -delta...delta)
self.delegate?.peripheral(self, didReadRSSI: (rssi + deviation) as NSNumber,
error: nil)
}
}
}
@available(iOS 11.0, tvOS 11.0, watchOS 4.0, *)
open func openL2CAPChannel(_ PSM: CBML2CAPPSM) {
fatalError("L2CAP mock is not implemented")
}
open override var hash: Int {
return mock.identifier.hashValue
}
}
// MARK: - Helpers
private class WeakRef<T: AnyObject> {
fileprivate private(set) weak var ref: T?
fileprivate init(_ value: T) {
self.ref = value
}
}
private extension Dictionary where Key == UUID, Value == CBMPeripheralMock {
subscript(identifiers: [UUID]) -> [CBMPeripheralMock] {
return identifiers.compactMap { self[$0] }
}
subscript(serviceUUIDs: [CBMUUID]) -> [CBMPeripheralMock] {
return filter { (_, peripheral) in
peripheral.services?
.contains(where: { service in
serviceUUIDs.contains(service.uuid)
})
?? false
}.map { $0.value }
}
}