Intro
A few years back, I was tasked with developing a geology application where location accuracy was very important. While iPhones have an accuracy of around 5 meters under ideal conditions — clear skies, no nearby buildings and unobstructed by trees — this was insufficient for our needs. My client required more precise data for location-based calculations, necessitating an external solution. After conducting some research, the client identified compatible GPS devices. However, these devices utilised Bluetooth SPP, which isn’t supported by CoreBluetooth. This meant that we were unable to connect to them inside the application. Luckily, these GPS devices were part of the MFI program, something I was about to become very familiar with.
What is MFi?
MFi, short for Made for iPhone/iPad/iPod, is a program that enables external devices to connect with ease to iPhones via the lightning cable or wirelessly through Bluetooth. Your application can communicate with MFi-certified devices using the ExternalAccessory, framework.
Getting approval from the manufacturer
Before submitting your application to the App Store, manufacturers typically require whitelisting to prevent rejection by Apple. This process involves providing essential details such as the application name, version number, bundle ID and a short description outlining your app’s purpose and how it utilises the connected device. Depending on the manufacturer’s requirements, additional information may also be requested.
Using ExternalAccessory
Once the whitelisting process is successfully completed, you will be issued an accessory protocol identifier structured under the scheme: com.company.protocol. Here, company refers to the manufacturer of the device, and protocol denotes the specific protocol associated with the device. This approach enables manufacturers to create multiple protocols tailored to their device’s functionalities while ensuring each identifier remains unique.
To ensure proper communication with the external device, the protocol must be specified in the Info.plist file under the UISupportedExternalAccessoryProtocols key. Here is an example of how to structure it:
UISupportedExternalAccessoryProtocols com.company.protocol
In this setup, replace com.company.protocol with the actual protocol identifier you received during the white-listing process. This inclusion is critical, as it informs iOS that your application supports the specified external accessory protocols, allowing seamless interaction with the connected hardware. If the protocol is not added to the list of supported external accessories in the Info.plist file, your application will be unable to establish a connection with the device.
Connecting to a device
Establishing a connection with an external accessory is straightforward. The ExternalAccessory framework provides developers with a singleton class called EAAccessoryManager. This class includes a method named showBluetoothAccessoryPicker, which is designed to help users select and connect to Bluetooth accessories. The method’s signature looks like this:
func showBluetoothAccessoryPicker(
withNameFilter predicate: NSPredicate?,
completion: EABluetoothAccessoryPickerCompletion? = nil
)
The predicate allows developers to filter available devices (setting it to nil will show all available devices). This enables users to quickly find and connect to the desired accessory. Meanwhile, the completion function provides a mechanism to handle any errors that may occur during the connection process. Here’s an example of how to use showBluetoothAccessoryPicker:
EAAccessoryManager.shared().showBluetoothAccessoryPicker(withNameFilter: nil) { error in
if let error {
print("Error occurred while connecting: \(error.localizedDescription)")
} else {
print("Accessory connected successfully.")
}
}
Naturally, after allowing users to select the device to connect to, it is important to respond to connection events. These events are dispatched as notifications, which are only received after being registered in the EAAccessoryManager. Here’s how you can register for these notifications:
EAAccessoryManager.shared().registerForLocalNotifications()
NotificationCenter.default
.publisher(for: .EAAccessoryDidConnect, object: nil)
.sink { notification in
guard let accessory = notification.userInfo?[EAAccessoryKey] as? EAAccessory else { return }
print("Accessory connected: \(accessory.name)")
// Handle the connection event
}
.store(in: &cancellables)
NotificationCenter.default
.publisher(for: .EAAccessoryDidDisconnect, object: nil)
.sink { notification in
guard let accessory = notification.userInfo?[EAAccessoryKey] as? EAAccessory else { return }
print("Accessory disconnected: \(accessory.name)")
// Handle the disconnection event
}
.store(in: &cancellables)
Reading & writing data
Finally, after obtaining a reference to the external accessory, you can read and write data from the device. Apple provides the EASession class, which manages communication between the application and the external device. Creating a session is straightforward, as demonstrated in the example below:
guard let protocolString = accessory.protocolStrings.first else { return }
let session = EASession(accessory: accessory, forProtocol: protocolString)
Using the EASession object is really intuitive. It provides two properties, inputStream and outputStream, which, as their names suggest, are used for reading data from the device and writing data to it. Although both of these properties are optional, they are automatically provided by the session object. To receive any events from these streams, they both must be configured as follows:
guard let inputStream = session.inputStream, let outputStream = session.outputStream else { return }
inputStream.delegate = self
inputStream.schedule(in: .current, forMode: .default)
inputStream.open()
outputStream.delegate = self
outputStream.schedule(in: .current, forMode: .default)
outputStream.open()
The next step is implementing the StreamDelegate method to handle the various stream events:
func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
switch eventCode {
case .hasBytesAvailable:
readBytes(from: aStream)
case .hasSpaceAvailable:
writeBytes(to: aStream)
case .errorOccurred:
print("Stream error occurred: \(String(describing: aStream.streamError))")
case .endEncountered:
print("Stream end encountered")
default:
break
}
}
Handling Stream Events with readBytes
The events generated by the stream are named suggestively and guide us in handling different scenarios. When there are bytes available for reading, we want to process those bytes effectively. The readBytes function is responsible for managing the input stream. Here’s a detailed breakdown of how to implement this:
- 1.Create a Buffer: A buffer with an arbitrary capacity (e.g: 1024 bytes) is used to read data from the input stream.
- 2.Read Data: The
readmethod reads data into the buffer and returns the number of bytes read. If the number is0, it indicates the end of the stream. A value of-1indicates an error, which can be accessed via thestreamErrorproperty. - 3.Process the Data: The content of the buffer is added to our final input value. Once the stream is emptied, we handle the input as desired.
func readBytes(from stream: Stream) {
guard let inputStream = stream as? InputStream else { return }
let bufferLength = 1024
var input: [UInt8] = []
var buffer = Array(repeating: 0, count: bufferLength)
while inputStream.hasBytesAvailable {
let bytesCount = inputStream.read(&buffer, maxLength: bufferLength)
switch bytesCount {
case 0:
print("End of stream")
case 1:
guard let error = inputStream.streamError else { break }
print("Stream error: \(String(describing: error))")
default:
input.append(contentsOf: buffer.prefix(bytesCount))
}
}
// Handle input
}
Writing to the Stream
Writing data to the stream is indeed simpler than reading. The process involves passing an array of bytes to the output stream, and the write method returns the number of bytes successfully written. A return value of 0 indicates that the stream has reached its capacity, while -1 signifies an error. Here’s a detailed explanation and implementation:
- 1.Prepare the Data: Convert the data you want to write into a byte array.
- 2.Write Data: Use the
writemethod of the output stream to send the data. Check the return value to handle cases where the stream is full or an error occurs.
func writeBytes(to stream: Stream) {
guard let outputStream = stream as? OutputStream else { return }
let bytes: [UInt8] = [0, 0, 0, 0]
let bytesCount = outputStream.write(bytes, maxLength: bytes.count)
if bytesCount == -1, let error = outputStream.streamError {
print("Stream error: \(String(describing: error))")
} else if bytesCount == 0 {
print("End of stream")
}
}
Sumary
To sum things up, communicating with an external device through the ExternalAccessory framework is straightforward but requires attention to a few key details. Here’s a concise overview:
- 1.Check MFi Compatibility:: Ensure your device is part of Apple’s MFi (Made for iPhone/iPad/iPod) program. This guarantees compatibility with iOS devices via Lightning cable or Bluetooth.
- 2.Get Whitelisted: Before submitting your app to the App Store, you must be whitelisted by the device manufacturer. Provide details like the app name, version, bundle ID, and a description of your app and its use of the device. Some manufacturers may require additional information.
- 3.Receive Protocol String: Once whitelisted, you’ll receive an accessory protocol identifier, which must be added to your app’s Info.plist file under the
UISupportedExternalAccessoryProtocolskey. - 4.Connect and Communicate: With these steps completed, you can connect to your device using the
EAAccessoryManagerandEASessionclasses. Set up input and output streams to read and write data, ensuring you handle stream events and errors appropriately.
Recent Comments