We’ve actually already done most of the work for this item, but let’s see if there’s anything more we can do (at this point).
Remember that one of our current restrictions is to have no changes in the apps, themselves, so anything that we do has to be in the SDK-src
directory. At this point, the Apps-src
directory is still at the same version it was when we started (Tag itcb-09).
Everything that we’ve done, to this point, has been in the SDK.
MISSING PERPIHERAL
One of the things about BLE, is that it’s designed to be quite robust. When we link up to a Peripheral from the Central, the CBPeripheral
instance stays around until we’re done with the Central.
If the device is no longer available while the Central has it listed, it isn’t always easy to figure that out. In my testing, I have found that the only reliable indicator is if the CBPeripheralDelegate.peripheral(_:, didModifyServices:)
call is received by the Central.
I have found that if I get this, then that means that the Peripheral has “disappeared” from the Central. I do not get the call that I expect, which is CBCentralManagerDelegate.centralManager(_:didDisconnectPeripheral:error:)
. I assume that’s because that call is only in response to the Central explicitly forcing a disconnect.
What I did, was add the CBPeripheralDelegate.peripheral(_:, didModifyServices:)
callback to the Peripheral instance, and return an error. In the future, we may want to modify this further, to be less intrusive, but we don’t want to change the app, so this will do, for now.
TIMEOUT
There is one thing that we can add. A timeout.
By default, Bluetooth LE operations don’t have a timeout. Well…there is a “sort of” timeout, but it’s ridiculously long. We should make our own.
The simplest way to do a timeout is with a trusty old Timer
. This is a simple, one-shot operation, and that’s what Timer
was designed for.
It should work like this:
- The
Timer
needs to be implemented in the Central; not the Peripheral. - When the Central sends the question, it should instantiate
Timer
as a “one-shot” (non-repeating) timer, and store a reference to this instance as a Central instance property. - When the Peripheral returns an answer, this instance property should be used to invalidate and delete the timer.
- If the operation times out, then the Central SDK instance should have a
Timer
callback (the “block” in the signature linked above), that “cleans up” the instance, and notifies observers of the error. - We really don’t need a long timeout. One second should be fine.
So let’s make ourselves a timeout…
The Starting Repo Tag
The Ending Repo Tag
The Comparison Between the Two Tags
WALKTHROUGH
I only changed one file.
The ITCB_SDK_Central_internal.swift
File
- I added a constant, to hold our timeout period (good coding practice -no “magic numbers“).
- I added a property to the Central class to hold the instance of
Timer
:
BEFORE:
internal class ITCB_SDK_Device_Peripheral: ITCB_SDK_Device, ITCB_Device_Peripheral_Protocol { internal var owner: ITCB_SDK_Central!
AFTER:
internal class ITCB_SDK_Device_Peripheral: ITCB_SDK_Device, ITCB_Device_Peripheral_Protocol { internal let _timeoutLengthInSeconds: TimeInterval = 1.0 internal var owner: ITCB_SDK_Central! internal var _timeoutTimer: Timer!
- I now instantiate a timeout timer when we ask the question.
- I added an error to be thrown if the Service can’t be accessed (what happens if the device disappears completely):
BEFORE:
public func sendQuestion(_ inQuestion: String) { question = nil if let data = inQuestion.data(using: .utf8), let peripheral = _peerInstance as? CBPeripheral, let service = peripheral.services?[_static_ITCB_SDK_8BallServiceUUID.uuidString], let questionCharacteristic = service.characteristics?[_static_ITCB_SDK_8BallService_Question_UUID.uuidString], let answerCharacteristic = service.characteristics?[_static_ITCB_SDK_8BallService_Answer_UUID.uuidString] { _interimQuestion = inQuestion peripheral.setNotifyValue(true, for: answerCharacteristic) peripheral.writeValue(data, for: questionCharacteristic, type: .withResponse) } }
AFTER:
public func sendQuestion(_ inQuestion: String) { question = nil if let data = inQuestion.data(using: .utf8), let peripheral = _peerInstance as? CBPeripheral, let service = peripheral.services?[_static_ITCB_SDK_8BallServiceUUID.uuidString], let questionCharacteristic = service.characteristics?[_static_ITCB_SDK_8BallService_Question_UUID.uuidString], let answerCharacteristic = service.characteristics?[_static_ITCB_SDK_8BallService_Answer_UUID.uuidString] { _timeoutTimer = Timer.scheduledTimer(withTimeInterval: _timeoutLengthInSeconds, repeats: false) { [unowned self] (_) in self._timeoutTimer = nil self.owner._sendErrorMessageToAllObservers(error: .sendFailed(ITCB_RejectionReason.deviceOffline)) } _interimQuestion = inQuestion peripheral.setNotifyValue(true, for: answerCharacteristic) peripheral.writeValue(data, for: questionCharacteristic, type: .withResponse) } else { self.owner._sendErrorMessageToAllObservers(error: .sendFailed(ITCB_RejectionReason.deviceOffline)) } }
That “[unowned self]
” thing is because the block is marked as “@escaping
,” so it’s a good idea to add a capture to the callback. I discuss that in this Swiftwater entry.
- I added “cleanup” code to two places where errors could be caught:
BEFORE:
public func peripheral(_ inPeripheral: CBPeripheral, didUpdateValueFor inCharacteristic: CBCharacteristic, error inError: Error?) { if let answerData = inCharacteristic.value, let answerString = String(data: answerData, encoding: .utf8), !answerString.isEmpty { inPeripheral.setNotifyValue(false, for: inCharacteristic) answer = answerString } }
AFTER:
public func peripheral(_ inPeripheral: CBPeripheral, didUpdateValueFor inCharacteristic: CBCharacteristic, error inError: Error?) { if let answerData = inCharacteristic.value, let answerString = String(data: answerData, encoding: .utf8), !answerString.isEmpty { _timeoutTimer.invalidate() _timeoutTimer = nil inPeripheral.setNotifyValue(false, for: inCharacteristic) answer = answerString } }
BEFORE:
public func peripheral(_ inPeripheral: CBPeripheral, didWriteValueFor inCharacteristic: CBCharacteristic, error inError: Error?) { if nil == inError { if let questionString = _interimQuestion { question = questionString } else { owner._sendErrorMessageToAllObservers(error: .sendFailed(ITCB_RejectionReason.peripheralError(nil))) } } else { if let error = inError as? CBATTError { switch error { case CBATTError.unlikelyError: owner._sendErrorMessageToAllObservers(error: .sendFailed(ITCB_Errors.coreBluetooth(ITCB_RejectionReason.questionPlease))) default: owner._sendErrorMessageToAllObservers(error: .sendFailed(ITCB_Errors.coreBluetooth(ITCB_RejectionReason.peripheralError(error)))) } } else { owner._sendErrorMessageToAllObservers(error: .sendFailed(ITCB_RejectionReason.unknown(inError))) } } }
AFTER:
public func peripheral(_ inPeripheral: CBPeripheral, didWriteValueFor inCharacteristic: CBCharacteristic, error inError: Error?) { if nil == inError { if let questionString = _interimQuestion { question = questionString } else { owner._sendErrorMessageToAllObservers(error: .sendFailed(ITCB_RejectionReason.peripheralError(nil))) } } else { _timeoutTimer.invalidate() _timeoutTimer = nil if let error = inError as? CBATTError { switch error { case CBATTError.unlikelyError: owner._sendErrorMessageToAllObservers(error: .sendFailed(ITCB_Errors.coreBluetooth(ITCB_RejectionReason.questionPlease))) default: owner._sendErrorMessageToAllObservers(error: .sendFailed(ITCB_Errors.coreBluetooth(ITCB_RejectionReason.peripheralError(error)))) } } else { owner._sendErrorMessageToAllObservers(error: .sendFailed(ITCB_RejectionReason.unknown(inError))) } } }
public func peripheral(_ inPeripheral: CBPeripheral, didModifyServices inInvalidatedServices: [CBService]) { owner._sendErrorMessageToAllObservers(error: .sendFailed(ITCB_Errors.coreBluetooth(ITCB_RejectionReason.deviceOffline))) }
TESTING
Testing these errors is a bit tricky. If we want to test the catch for the device disappearing, then we can simply switch off the Peripheral after it has been discovered by the Central, but if we want to test the timeout, then we should comment out the contents of the Peripheral “_sendRandomAnswerToThisQuestion(_:)
” method; like so:
func _sendRandomAnswerToThisQuestion(_ inQuestion: String) { // guard let central = central as? ITCB_SDK_Device_Central else { return } // central.sendAnswer(String(format: "SLUG-ANSWER-%02d", Int.random(in: 0..<20)), toQuestion: inQuestion) }
…and ask a question. This would prevent the answer from being sent, and cause the Central to invoke the timeout handler.
WHERE WE ARE NOW
There’s no visible difference to the apps, except in the case of errors.