Bluetooth 2 Logo

Improving an SDK With Core Bluetooth – Automatic Responses

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

The goal of this exercise is to have the Peripheral (the “Magic 8-Ball” device) respond immediately, and completely randomly, to questions asked by the Central; without presenting a list of questions to the user of the Peripheral Mode app.

The Starting Repo Tag

The Ending Repo Tag

The Comparison Between the Two Tags

WALKTHROUGH

First, I upped the version numbers on the frameworks. Housekeeping.

I only made changes to one single source file in the SDK-src directory.

The ITCB_SDK_Peripheral_Internal.swift File

Only the iOS SDK and the MacOS SDK will be affected (WatchOS and TVOS do not support Peripheral Mode).

I did a little bit of “clean-up,” to make the file align with my style:

I beefed up the error reporting just a tiny bit, by adding a handler to the state change callback:

BEFORE:

    public func peripheralManagerDidUpdateState(_ inPeripheralManager: CBPeripheralManager) {
        if .poweredOn == inPeripheralManager.state {
            if let manager = peripheralManagerInstance {
                let mutableServiceInstance = CBMutableService(type: _static_ITCB_SDK_8BallServiceUUID, primary: true)
                _setCharacteristicsForThisService(mutableServiceInstance)
                inPeripheralManager.add(mutableServiceInstance)
                inPeripheralManager.startAdvertising([CBAdvertisementDataServiceUUIDsKey: [mutableServiceInstance.uuid],
                                                      CBAdvertisementDataLocalNameKey: localName
                ])
            }
        }
    }

AFTER:

    public func peripheralManagerDidUpdateState(_ inPeripheralManager: CBPeripheralManager) {
        switch inPeripheralManager.state {
        case .poweredOn:
            if let manager = peripheralManagerInstance {
                let mutableServiceInstance = CBMutableService(type: _static_ITCB_SDK_8BallServiceUUID, primary: true)
                _setCharacteristicsForThisService(mutableServiceInstance)
                inPeripheralManager.add(mutableServiceInstance)
                inPeripheralManager.startAdvertising([CBAdvertisementDataServiceUUIDsKey: [mutableServiceInstance.uuid],
                                                      CBAdvertisementDataLocalNameKey: localName
                ])
            }
        
        default:
            _sendErrorMessageToAllObservers(error: .coreBluetooth(nil))
        }
    }

The chances are good that this error will never be reported, but one way to get it reported every time, is to run the device as Peripheral, but use the simulator:

Yeah, It’s Unhelpful, But It’s There

That error showed up because the state was .poweredOff.

Later, we can get around to making the error reporting more helpful, but this can be considered an “interim” solution. I didn’t want to spend too much time on this.

Next, I tightened up the permissions in the method that I used to add the Characteristics to the Service:

BEFORE:

    func _setCharacteristicsForThisService(_ inMutableServiceInstance: CBMutableService) {
        let questionProperties: CBCharacteristicProperties = [.writeWithoutResponse]
        let answerProperties: CBCharacteristicProperties = [.read, .notify]
        let permissions: CBAttributePermissions = [.readable, .writeable]

        let questionCharacteristic = CBMutableCharacteristic(type: _static_ITCB_SDK_8BallService_Question_UUID, properties: questionProperties, value: nil, permissions: permissions)
        let answerCharacteristic = CBMutableCharacteristic(type: _static_ITCB_SDK_8BallService_Answer_UUID, properties: answerProperties, value: nil, permissions: permissions)
        
        inMutableServiceInstance.characteristics = [questionCharacteristic, answerCharacteristic]
    }

AFTER:

    func _setCharacteristicsForThisService(_ inMutableServiceInstance: CBMutableService) {
        let questionProperties: CBCharacteristicProperties = [.writeWithoutResponse]
        let answerProperties: CBCharacteristicProperties = [.read, .notify]
        let questionPermissions: CBAttributePermissions = [.writeable]
        let answerPermissions: CBAttributePermissions = [.readable]

        let questionCharacteristic = CBMutableCharacteristic(type: _static_ITCB_SDK_8BallService_Question_UUID, properties: questionProperties, value: nil, permissions: questionPermissions)
        let answerCharacteristic = CBMutableCharacteristic(type: _static_ITCB_SDK_8BallService_Answer_UUID, properties: answerProperties, value: nil, permissions: answerPermissions)
        
        inMutableServiceInstance.characteristics = [questionCharacteristic, answerCharacteristic]
    }

These were all basically “housekeeping” changes; designed to improve code clarity, error handling and security. The only one that resulted in any visible change was the error report for non-powered-on states.

THE “MEAT” OF THE CHANGES:

Now, let’s look at what we did to add the automatic response to the Peripheral.

First, I added a stored property to the device class to hold the question that was asked (I’ll get around to why in just a bit).

Next, I created an internal method to generate and send a random answer to the Central:

    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)
    }

What I do there, is first make sure that I have a valid Central device instance (the guard statement), then use its sendAnswer(-:, toQuestion:) method to send a random String, which we select from our pool. I’ll use the localization system to select what is displayed, so I return a numbered “slug.”

Realistically, we don’t need to hang onto the question, as we’re just spewing out a random answer, but I wanted to keep the changes as minor as possible. I guess I could have just sent an empty string as the toQuestion value.

That said, it’s always easier to take away data than add it after the fact. If I still think it’s not necessary in the next set of exercises, where I plan to work on the apps, I’ll probably delete it then.

I then modified the callback that is invoked when a property changes:

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 {
            central = ITCB_SDK_Device_Central(inWriteRequests[0].central, owner: self)
        }
        
        _sendQuestionAskedToAllObservers(device: central, question: 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
        }

        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)
        }
    }

What I did here, was check to see if we already had a Central, and decide what to do next, based on that.

This is because it is possible to get the subscription and write request “out of order.” We can’t just assume that the Central is ready to hear the answer when the question is asked.

In the previous version, this didn’t matter, because we were presenting a UI to the user, and waiting for them to respond, giving the SDK plenty of time to get the subscription set up. This time, we are responding instantly, so timing matters.

In that case (we detect it by seeing that we don’t have a Central cached), we create a new instance of the Central device, add the question to its _question stored property (that property I added in the previous step), and just let the program continue without doing anything more. The _question stored property will carry our value into the next phase.

If we already have a Central, then there’s no need to cache the question. We can simply send our random answer immediately.

Note that I removed the observer _sendQuestionAskedToAllObservers(device:, question:) callback. That’s because we are no longer presenting a “before the fact” UI to the user. They will get the “after the fact” report that they were already going to get.

I also modified the callback for when a subscription occurs:

BEFORE:

    public func peripheralManager(_ inPeripheralManager: CBPeripheralManager, central inCentral: CBCentral, didSubscribeTo inCharacteristic: CBCharacteristic) {
        if nil == central {
            central = ITCB_SDK_Device_Central(inCentral, owner: self)
        }
        
        if  let central = central as? ITCB_SDK_Device_Central {
            central.subscribedChar = inCharacteristic
        }
    }

AFTER:

    public func peripheralManager(_ inPeripheralManager: CBPeripheralManager, central inCentral: CBCentral, didSubscribeTo inCharacteristic: CBCharacteristic) {
        if nil == central {
            central = ITCB_SDK_Device_Central(inCentral, owner: self)
        }
        
        if  let central = central as? ITCB_SDK_Device_Central {
            central._subscribedChar = inCharacteristic
            if let question = central._question {
                _sendRandomAnswerToThisQuestion(question)
            }
        }
    }

In this method, we now check to see if the Central’s _question property was set. If so, then we know that we are ready, and can immediately send the answer. If not, we just do what we did before, which was set the subscribed property, and let the program continue.

WHERE WE ARE NOW

At this point, the Central behavior hasn’t changed at all. The changes that we made only affect the Peripheral Mode, so that means that only the MacOS and iOS/iPadOS targets are affected.

In the screengrabs below, note that the Peripheral Mode screens have the alerts displayed over the “WAITING” screen, as opposed to the “Select An Answer” screen, which won’t be displayed anymore.

Peripheral Mode on iOS
Peripheral Mode on MacOS
Central Mode on WatchOS
Central Mode on TVOS

To wrap this up in a quick summary: We just made an enormous functional change to the system, without touching a line of code in the client app. That’s why a loosely-coupled, S.Q.U.I.D. SDK is so nice.

Now that we have checked off our first TODO, let’s move on to making connections temporary.