iosswiftarchitecturecbperipheral

How to implement multiple CBPeripherals in app


I am writing an application that will work with Bluetooth Devices. At first, there was only one device and all the application logic was rigidly built around it (ViewController and Model for it). However, the list of devices will grow and I need to achieve good scalability of the code. The fact is that all devices obey a single protocol, that is, the Services and Characteristics are the same for all, just some devices support something, while others do not. I immediately realized that describing a separate Model for each device would be a very cumbersome decision, since there would be a lot of repetitive code. At first, I created an abstract device class BasePeripheral, which describes the basic things (connecting, disconnecting, scanning services and characteristics), then inherited from it specific devices that implement the corresponding protocols for them. In general, such a solution made the task a little easier, but I am still sure that this is far from ideal. I am not experienced enough and therefore I want to be prompted in which direction to work for me. At first it seemed to me that the solution might be to use Generics, but it was difficult to immediately understand their work for my purposes. There is also a problem of recognizing the type of devices, at the moment this is done with a clumsy Switch-Case design, and I am not very pleased with its use, for example, to recognize 50 different devices. I would like to know what things I should learn to achieve my goal (and with code examples it would be even nicer)

protocol ColorPeripheral {
    func writeColor(_ color: Int)
}

protocol AnimationPeriheral {
    func writeAnimation(_ animation: Int)
    func writeAnimationSpeed(_ speed: Int)
}

protocol DistancePeripheral {
    // No write properties. Only readable
}

protocol IndicationPeripheral {
    // No write properties. Only readable
}

protocol PeripheralDataDelegate {
    func getColor(_ color: Any)
    func getAnimation(_ animation: Any)
    func getAnimationSpeed(_ speed: Any)
    func getDistance(_ distance: Any)
    func getLedState(_ state: Any)
}

class BasePeripheral: NSObject,
                      CBCentralManagerDelegate,
                      CBPeripheralDelegate {
    
    let centralManager: CBCentralManager
    let basePeripheral: CBPeripheral
    let advertisingData: [String : Any]
    public private(set) var advertisedName: String = "Unknown Device".localized
    public private(set) var RSSI          : NSNumber
    public var type : BasePeripheral.Type?
    public var services: [CBUUID]?
    public var delegate: PeripheralDataDelegate?
    
    init(withPeripheral peripheral: CBPeripheral,
         advertisementData advertisementDictionary: [String : Any],
         andRSSI currentRSSI: NSNumber,
         using manager: CBCentralManager) {
        self.centralManager = manager
        self.basePeripheral = peripheral
        self.advertisingData = advertisementDictionary
        self.RSSI = currentRSSI
        super.init()
        self.advertisedName = getAdvertisedName(advertisementDictionary)
        self.type = getAdvertisedService(advertisementDictionary)
        self.basePeripheral.delegate = self
    }
    
    private func getAdvertisedName(_ advertisementDictionary: [String : Any]) -> String {
        var advertisedName: String
        if let name = advertisementDictionary[CBAdvertisementDataLocalNameKey] as? String {
            advertisedName = name
        } else {
            advertisedName = "Unknown Device".localized
        }
        return advertisedName
    }
        
    // Getting device type
    private func getAdvertisedService(_ advertisementDictionary: [String : Any]) -> BasePeripheral.Type? {
        if let advUUID = advertisementDictionary[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] {
            for uuid in advUUID {
                // Getting type from [CBUUID:BasePeripheral.Type] dictionary
                if let service = UUIDs.advServices[uuid] {
                    return service
                }
            }
        }
        return nil
    }
    
    func connect() {
        centralManager.delegate = self
        centralManager.connect(basePeripheral, options: nil)
        print("Connecting to \(advertisedName)")
    }
    
    func disconnect() {
        centralManager.cancelPeripheralConnection(basePeripheral)
        print("Disconnecting from \(advertisedName)")
    }
    
    func didDiscoverService(_ service:CBService) {}
    
    func didDiscoverCharacteristic(_ characteristic: CBCharacteristic) {}
    
    func didReceiveData(from characteristic: CBCharacteristic) {}
    
    // MARK: - CBCentralManagerDelegate
    
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        if central.state != .poweredOn {
            print("Central Manager state changed to \(central.state)")
        }
    }
    
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        print("Connected to \(self.advertisedName )")
        basePeripheral.discoverServices(services)
    }
    
    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
        print("Disconnected from \(self.advertisedName ) - \(String(describing: error?.localizedDescription))")
    }
    
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        if let services = peripheral.services {
            for service in services {
                didDiscoverService(service)
            }
        }
    }
    
    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
        if let characteristics = service.characteristics {
            for characteristic in characteristics {
                didDiscoverCharacteristic(characteristic)
            }
        }
    }
    
    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
        didReceiveData(from: characteristic)
    }

}

class FirstPeripheral: BasePeripheral, ColorPeripheral, AnimationPeriheral {
    
    private var colorCharacteristic         : CBCharacteristic?
    private var animationCharacteristic     : CBCharacteristic?
    private var animationSpeedCharacteristic: CBCharacteristic?

    init(superPeripheral: BasePeripheral) {
        super.init(withPeripheral:    superPeripheral.basePeripheral,
                   advertisementData: superPeripheral.advertisingData,
                   andRSSI:           superPeripheral.RSSI,
                   using:             superPeripheral.centralManager)
        super.services = [UUIDs.COLOR_SERVICE_UUID,
                          UUIDs.ANIMATION_SERVICE_UUID]
    }
    
    // ColorPeripheral protocol
    func writeColor(_ color: Int) {
        if let characteristic = colorCharacteristic {
            var value = UInt8(color)
            let data = Data(bytes: &value, count: 1)
            basePeripheral.writeValue(data, for: characteristic, type: .withoutResponse)
        }
    }
    
    // AnimationPeripheral protocol
    func writeAnimation(_ animation: Int) {
        if let characteristic = animationCharacteristic {
            var value = UInt8(animation)
            let data = Data(bytes: &value, count: 1)
            basePeripheral.writeValue(data, for: characteristic, type: .withoutResponse)
        }
    }
    
    func writeAnimationSpeed(_ speed: Int) {
        if let characteristic = animationSpeedCharacteristic {
            var value = UInt8(speed)
            let data = Data(bytes: &value, count: 1)
            basePeripheral.writeValue(data, for: characteristic, type: .withoutResponse)
        }
    }
    
    override func didReceiveData(from characteristic: CBCharacteristic) {
        switch characteristic {
        case colorCharacteristic:
            delegate?.getColor(characteristic.value!)
            break
        case animationCharacteristic:
            delegate?.getAnimation(characteristic.value!)
            break
        case animationSpeedCharacteristic:
            delegate?.getAnimationSpeed(characteristic.value!)
            break
        default:
            print("Unknown value changed")
        }
    }
    
    override func didDiscoverService(_ service: CBService) {
        switch service.uuid {
        case UUIDs.COLOR_SERVICE_UUID:
            // Discover color characteristics
            break
        case UUIDs.ANIMATION_SERVICE_UUID:
            // Discover animation characteristics
            break
        default:
            print("Unknown service found - \(service.uuid)")
        }
    }
    
    override func didDiscoverCharacteristic(_ characteristic: CBCharacteristic) {
        switch characteristic.uuid {
        case UUIDs.COLOR_PRIMARY_CHARACTERISTIC_UUID:
            colorCharacteristic = characteristic
            break
        case UUIDs.ANIMATION_MODE_CHARACTERISTIC_UUID:
            animationCharacteristic = characteristic
            break
        case UUIDs.ANIMATION_ON_SPEED_CHARACTERISTIC_UUID:
            animationSpeedCharacteristic = characteristic
            break
        default:
            print("Unknown characteristic found \(characteristic.uuid)")
        }
    }
    
}

class SecondPeripheral: BasePeripheral, ColorPeripheral, DistancePeripheral, IndicationPeripheral {
    
    private var colorCharacteristic      : CBCharacteristic?
    private var distanceCharacteristic   : CBCharacteristic?
    private var indicationCharacteristic : CBCharacteristic?
        
    init(superPeripheral: BasePeripheral) {
        super.init(withPeripheral:    superPeripheral.basePeripheral,
                   advertisementData: superPeripheral.advertisingData,
                   andRSSI:           superPeripheral.RSSI,
                   using:             superPeripheral.centralManager)
        super.services = [UUIDs.COLOR_SERVICE_UUID,
                          UUIDs.DISTANCE_SERVICE_UUID,
                          UUIDs.INDICATION_SERVICE_UUID]
    }
    
    // ColorPeripheral protocol
    func writeColor(_ color: Int) {
        if let characteristic = colorCharacteristic {
            var value = UInt8(color)
            let data = Data(bytes: &value, count: 1)
            basePeripheral.writeValue(data, for: characteristic, type: .withoutResponse)
        }
    }
    
    override func didReceiveData(from characteristic: CBCharacteristic) {
        switch characteristic {
        case colorCharacteristic:
            delegate?.getColor(characteristic.value!)
            break
        case distanceCharacteristic:
            delegate?.getDistance(characteristic.value!)
            break
        case indicationCharacteristic:
            delegate?.getLedState(characteristic.value!)
            break
        default:
            print("Unknown value changed")
        }
    }
    
    override func didDiscoverService(_ service: CBService) {
        switch service.uuid {
        case UUIDs.COLOR_SERVICE_UUID:
            // Discover color characteristics
            break
        case UUIDs.DISTANCE_SERVICE_UUID:
            // Discover distance characteristics
            break
        case UUIDs.INDICATION_SERVICE_UUID:
            // Discover indication characteristics
        default:
            print("Unknown service found - \(service.uuid)")
        }
    }
    
    override func didDiscoverCharacteristic(_ characteristic: CBCharacteristic) {
        switch characteristic.uuid {
        case UUIDs.COLOR_PRIMARY_CHARACTERISTIC_UUID:
            colorCharacteristic = characteristic
            break
        case UUIDs.DISTANCE_CHARACTERISTIC_UUID:
            distanceCharacteristic = characteristic
            break
        case UUIDs.INDICATION_CHARACTERISTIC_UUID:
            indicationCharacteristic = characteristic
        default:
            print("Unknown characteristic found \(characteristic.uuid)")
        }
    }
    
}

Solution

  • This is very case specific but going into direction of protocols may be a nice way of doing it. But you must use protocol extensions a lot. To give a simple example:

    You should declare your base-most structure as a protocol. As you said all of them connect to Bluetooth and all of them will have same characteristics that you wish to use. So:

    protocol BasicPeripheralInterface {
        
        var peripheral: CBPeripheral { get }
        var firstCharacteristic: CBCharacteristic { get }
        var secondCharacteristic: CBCharacteristic { get }
        
    }
    

    now you wish to expose some interface over it which may change depending on device type. Let's send some color to it for instance:

    protocol ColorPeripheralInterface {
        func setColor(_ color: UIColor)
    }
    

    nothing drastic so far. But now we can connect the two through extension of the protocol like this:

    extension ColorPeripheralInterface where Self:BasicPeripheralInterface {
        
        func setColor(_ color: UIColor) {
            peripheral.writeValue(color.toData(), for: firstCharacteristic, type: .withoutResponse)
        }
        
    }
    

    by doing this we are saying that every object that is BasicPeripheralInterface and is also ColorPeripheralInterface will already have the logic described in the extension. So to create such an object we would do something like

    class MyDevice: BasicPeripheralInterface, ColorPeripheralInterface {
        
        let peripheral: CBPeripheral
        let firstCharacteristic: CBCharacteristic
        let secondCharacteristic: CBCharacteristic
        
        init(peripheral: CBPeripheral, firstCharacteristic: CBCharacteristic, secondCharacteristic: CBCharacteristic) {
            self.peripheral = peripheral
            self.firstCharacteristic = firstCharacteristic
            self.secondCharacteristic = secondCharacteristic
        }
        
    }
    

    this class now implements logic needed for BasicPeripheralInterface but has no logic related to ColorPeripheralInterface at all. There is no need to implement setColor because this method is already in extension. And the following code works as expected:

    func sendColor(toDevice device: MyDevice) {
        device.setColor(.red)
    }
    

    Now when we add more protocols and more devices we would expect something like this:

    protocol ColorPeripheralInterface {
        func setColor(_ color: UIColor)
    }
    
    extension ColorPeripheralInterface where Self:BasicPeripheralInterface {
        
        func setColor(_ color: UIColor) {
            peripheral.writeValue(color.toData(), for: firstCharacteristic, type: .withoutResponse)
        }
        
    }
    
    protocol StringPeripheralInterface {
        func setText(_ text: String)
    }
    
    extension StringPeripheralInterface where Self:BasicPeripheralInterface {
        
        func setText(_ text: String) {
            peripheral.writeValue(text.data(using: .utf8)!, for: secondCharacteristic, type: .withoutResponse)
        }
        
    }
    
    protocol AmountPeripheralInterface {
        func setAmount1(_ value: Int)
        func setAmount2(_ value: Int)
    }
    
    extension AmountPeripheralInterface where Self:BasicPeripheralInterface {
        
        func setAmount1(_ value: Int) {
            peripheral.writeValue("\(value)".data(using: .utf8)!, for: firstCharacteristic, type: .withoutResponse)
        }
        func setAmount2(_ value: Int) {
            peripheral.writeValue("\(value)".data(using: .utf8)!, for: secondCharacteristic, type: .withoutResponse)
        }
        
    }
    

    Now this just defines 3 more protocols which extend some silly logic. But now these protocols can be applied to and define any device you like in any combination that you need. First a base class makes sense

    class BasicDevice: BasicPeripheralInterface {
        
        let peripheral: CBPeripheral
        let firstCharacteristic: CBCharacteristic
        let secondCharacteristic: CBCharacteristic
        
        init(peripheral: CBPeripheral, firstCharacteristic: CBCharacteristic, secondCharacteristic: CBCharacteristic) {
            self.peripheral = peripheral
            self.firstCharacteristic = firstCharacteristic
            self.secondCharacteristic = secondCharacteristic
        }
        
    }
    

    this one will not be used directly. But the magic lies in declaring concrete devices:

    class StringDevice: BasicDevice, StringPeripheralInterface {  }
    class AmountStringDevice: BasicDevice, StringPeripheralInterface, AmountPeripheralInterface {  }
    class LatestAllSupportingDevice: BasicDevice, StringPeripheralInterface, AmountPeripheralInterface, ColorPeripheralInterface {  }
    

    So these are 3 classes where each of them extends different interface for different capabilities can now be used as concrete device classes. Each of them only holds methods that are defined in protocols assigned to them and nothing more.

    This way you could easily define 50 devices where for each of them you only need to list all protocols it corresponds to, nothing more.

    And if a certain device has just one method a little bit different you can always override it. For instance:

    class ThisNewDeviceThatDoesThingsADashDifferently: BasicDevice, StringPeripheralInterface, AmountPeripheralInterface, ColorPeripheralInterface {
        
        func setAmount2(_ value: Int) {
            peripheral.writeValue("2-\(value)".data(using: .utf8)!, for: firstCharacteristic, type: .withoutResponse)
        }
        
    }
    

    This class now does everything exactly the same as LatestAllSupportingDevice but overrides setAmount2 to do things a bit differently.

    You should also know that extending protocols is not limited to just protocols when it comes to where Self:. You could easily simply remove BasicPeripheralInterface and use BasicDevice everywhere such as extension ColorPeripheralInterface where Self:BasicDevice.

    There is a lot you can do using this approach. But in the end it may or may not be an appropriate one for you to use. Technically speaking any answer to this question is opinion-based.