Bluetooth 2 Logo

Improving an SDK With Core Bluetooth – Error Handling

This entry is part 6 of 7 in the series Improving an SDK With Core Bluetooth

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

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!

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.

Let’s wrap things up…