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