Bluetooth 2 Logo

Improving an SDK With Core Bluetooth – Confirmed Delivery

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

One thing that we can do, is make sure that the Peripheral actually gets our question. Right now, we simply rely on the fact that we sent it to confirm its receipt.

We would like to wait until the Peripheral actually tells us that it received the question before we assume that it has been sent.

The Starting Repo Tag

The Ending Repo Tag

The Comparison Between the Two Tags

WALKTHROUGH

I made a few changes to get this to happen. As per our mandate, I didn’t make any changes to the apps, but I did find a couple of “rough spots” that will need to be dealt with, once we get to working on the apps:

  1. There’s a bug, where the Peripheral “question” property isn’t properly optional-chained before being checked, so the app can crash, if we call its questionAskedOfDevice(_:) callback with a nil “question” property (one of the drawbacks of using implicitly-unwrapped optionals –but maybe not a drawback, per se, as I find bugs this way. I think crashes are good –as long as they happen during testing–, as they quickly and explicitly find bugs).
    The bug is in the app, but I addressed it by not making that callback if the property is nil. I need to be able to set it to nil, in order to make this all work.
  2. The error reporting is pretty awful. I can do better.
    For this iteration, I just make sure that the SDK returns good errors. I’ll get around to interpreting them better in the app at a later time.

The ITCB_SDK_Protocol.swift File

This is the only file in the “public face” that was modified.

I added a couple of new error/rejection reasons:

  • questionPlease
    I now have added the requirement to the UX that all questions end with a question mark “?”. If the last character of a question is not a question mark, then the Central Mode app will receive a rejection from the Peripheral, referencing this reason. Not particularly localization-friendly, but this is really just a teaching gig, so I figured I could get away with it. I don’t want to localize the frameworks; just the apps.
  • peripheralError
    This is a general “Peripheral has a problem” error. The standard CoreBluetooth errors aren’t particularly helpful, here; being concerned mostly about transport issues. Internally, I use the .unlikelyError, and interpret that, based on the context, so I can give the Central Mode app a bit more granularity.

The ITCB_SDK_Central_internal.swift File

This file saw the most change.

  • I added the _interimQuestion property to the Peripheral class.
    This will be used as a “transitional” value that will hold the question between the time the user asks it, and the Peripheral device confirms receipt of it.
  • I changed the Peripheral sendQuestion(_:) Method
    This was so that we don’t immediately set the question property when we send the question. Instead, we set the new interim property. It was also important to make sure that the question property is cleared, even if there was nothing sent in, so I moved it out of the test context.
    Additionally, I took the liberty of switching the order of the write request and notify calls. It probably won’t make any difference, but things are happening a bit faster, now, so it doesn’t hurt.

BEFORE:

    public func sendQuestion(_ inQuestion: String) {
        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] {
            peripheral.writeValue(data, for: questionCharacteristic, type: .withoutResponse)
            peripheral.setNotifyValue(true, for: answerCharacteristic)
            question = inQuestion
        } else {
            question = nil
        }
    }

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] {
            _interimQuestion = inQuestion
            peripheral.setNotifyValue(true, for: answerCharacteristic)
            peripheral.writeValue(data, for: questionCharacteristic, type: .withResponse)
        }
    }
  • I removed the “placeholder” peripheral(_:, didModifyServices:) method.
    It seems that you need to support one of the callbacks if you declare yourself to be a CBPeripheralDelegate, and having the other ones satisfied the need, so this placeholder was no longer necessary.
  • I added the peripheral(_:, didWriteValueFor:, error:) method.
    This is where we check for the update of the question Characteristic.
    Note that I also added some error checking, and this is where those new enum values come into play.
    This method is where we figure out whether or not our question was accepted on the other end.
    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)))
            }
        }
    }

NOTE: In the above method, if you check the actual Characteristic value in the didWriteValueFor method, it will be nil, even though the value was successfully written. That’s because the Peripheral did not transmit the new value back to the Central. This is why I had to save the question in the _interimQuestion property. If I wanted to get the value from the Peripheral, I would need to issue a read request, which wouldn’t work, as the Characteristic is write-only.

I covered this in the “What’s Going On?” entry.

The ITCB_SDK_Peripheral_internal.swift File

This is where the Peripheral “vets” the question, and either accepts it, or rejects it.

BEFORE:

    public func peripheralManager(_ inPeripheralManager: CBPeripheralManager, didReceiveWrite inWriteRequests: [CBATTRequest]) {
        guard   1 == inWriteRequests.count,
                let mutableChar = inWriteRequests[0].characteristic as? CBMutableCharacteristic,
                let data = inWriteRequests[0].value,
                let stringVal = String(data: data, encoding: .utf8) else {
            return
        }

        mutableChar.value = data
        
        if nil == central {
            let tempCentral = ITCB_SDK_Device_Central(inWriteRequests[0].central, owner: self)
            tempCentral._question = stringVal
            central = tempCentral
        } else {
            _sendRandomAnswerToThisQuestion(stringVal)
        }
    }

AFTER:

    public func peripheralManager(_ inPeripheralManager: CBPeripheralManager, didReceiveWrite inWriteRequests: [CBATTRequest]) {
        guard   1 == inWriteRequests.count,
                let mutableChar = inWriteRequests[0].characteristic as? CBMutableCharacteristic,
                let data = inWriteRequests[0].value,
                let stringVal = String(data: data, encoding: .utf8) else {
            return
        }

        guard "?" == stringVal.last else {
            inPeripheralManager.respond(to: inWriteRequests[0], withResult: .unlikelyError)
            return
        }
        
        mutableChar.value = data
        inPeripheralManager.respond(to: inWriteRequests[0], withResult: .success)
        
        if nil == central {
            let tempCentral = ITCB_SDK_Device_Central(inWriteRequests[0].central, owner: self)
            tempCentral._question = stringVal
            central = tempCentral
        } else {
            _sendRandomAnswerToThisQuestion(stringVal)
        }
    }

I added support for the CBPeripheralManager.respond(to:, withResult:) method. I check the question, to make sure that it ends with a question mark (and ask the Central to phrase it as a question, if it does not). If it makes it past that, then I simply say it was a success.

BEFORE:

    public func sendAnswer(_ inAnswer: String, toQuestion inToQuestion: String) {
        if  let peripheralManager = owner.peripheralManagerInstance,
            let central = owner.central as? ITCB_SDK_Device_Central,
            let centralDevice = central.centralDeviceInstance,
            let answerCharacteristic = central._subscribedChar as? CBMutableCharacteristic,
            let data = inAnswer.data(using: .utf8) {
            peripheralManager.updateValue(data, for: answerCharacteristic, onSubscribedCentrals: [centralDevice])
            owner._sendSuccessInSendingAnswerToAllObservers(device: self, answer: inAnswer, toQuestion: inToQuestion)
        }
    }

AFTER:

    public func sendAnswer(_ inAnswer: String, toQuestion inToQuestion: String) {
        if  let peripheralManager = owner.peripheralManagerInstance,
            let central = owner.central as? ITCB_SDK_Device_Central,
            let centralDevice = central.centralDeviceInstance,
            let answerCharacteristic = central._subscribedChar as? CBMutableCharacteristic,
            let data = inAnswer.data(using: .utf8) {
            peripheralManager.updateValue(data, for: answerCharacteristic, onSubscribedCentrals: [centralDevice])
            owner._sendSuccessInSendingAnswerToAllObservers(device: self, answer: inAnswer, toQuestion: inToQuestion)
        }
        
        _question = nil
    }

WHERE WE ARE NOW

At this point, the apps behave exactly as they did before (except that they throw an error if you don’t end with a question mark).

Under the hood, though, the Central will not display the asked question until the Peripheral has confirmed delivery. If there is an error, the question remains in the text field, and “ERROR” is displayed in the log.

Let’s move on to the last item, error handling.