Observe serial ports on macOS
macOS serial swift Estimated reading time: 7 minutesRecently I played a bit within Arduino using Visual Code. After switching to M1 mac, I faced with issue, that visual-code extension for Arduino does not allow to select of a serial port and to open communication channel with the board.
As result, every time I must check all available serial ports, and put a name into the arduino.json
config file. And when I need to communicate with port - I should also use either Arduino IDE either some other tools.
I believe that fix will be created soon, but, doing the same thing, again and again, is a bit annoying, so I decided to automate a bit this process, and one of the step, required for this - is to simplify getting of serial port list and communication via serial port.
To do so, I decided to create a menu bar extra’s app, that in few clicks can provide some common actions related to serial ports on the system.
Serial port observation
In this app, I added a view, that displays the list of serial ports, reflects any changes in it (on connection via USB Arduino board for example), and allows copy name or/and open a communication view with it.

I already covered few points related to this app here and here
To solve this task, I looked into available options and decided to use IOKit
from Apple.
getting serial ports data
To find all serial ports with IOKit
we should create a set of parameter that we can use for search and then look up registered IOService
objects that match a matching dictionary:
var result: kern_return_t = KERN_FAILURE
let classesToMatch = IOServiceMatching(kIOSerialBSDServiceValue)
result = IOServiceGetMatchingServices(
kIOMasterPortDefault,
classesToMatch,
&serialPortIterator
)
kern_return_t
will contain the result of the operation - KERN_SUCCESS
if everything is fine, or other code.
Function IOServiceMatching
creates for us matching dictionary with base parameters, required for search.
U can add additional parameters in it, using small dance between types:
var matchingDict = IOServiceMatching(kIOSerialBSDServiceValue) as NSDictionary as! [String: AnyObject]
matchingDict[kIOSerialBSDTypeKey] = kIOSerialBSDAllTypes as AnyObject
let cfMatchingDictionary = matchingDict as CFDictionary
Check API declaration
IOSerialKeys.h
for other keys:
Sample Matching dictionary
{
IOProviderClass = kIOSerialBSDServiceValue;
kIOSerialBSDTypeKey = kIOSerialBSDAllTypes
| kIOSerialBSDModemType
| kIOSerialBSDRS232Type;
kIOTTYDeviceKey = <Raw Unique Device Name>;
kIOTTYBaseNameKey = <Raw Unique Device Name>;
kIOTTYSuffixKey = <Raw Unique Device Name>;
kIOCalloutDeviceKey = <Callout Device Name>;
kIODialinDeviceKey = <Dialin Device Name>;
}
> Note only the IOProviderClass is mandatory. The other keys
> allow the searcher to reduce the size of the set of matching
> devices.
U can also specify other search parameters for devices. For example, to search USB, u should add additional key/value to matching dictionary (
kUSBVendorID
,kUSBProductID
):
// vendor
vendorID = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &usbVendor);
CFDictionarySetValue(matchingDict, CFSTR(kUSBVendorID), vendorID);
Now, we have a iterator io_iterator_t
, by using which we can get all the elements:
struct SerialPort: Identifiable, Codable {
var id: String {
"\(bsdPath.hash)"
}
/// IOCalloutDevice
var bsdPath: String
/// IOTTYBaseName
var ttyName: String?
/// IOTTYDevice
var ttyDevice: String?
/// IODialinDevice
var dialinPath: String?
}
func extractSerialPaths(portIterator: io_iterator_t) -> [SerialPort] {
var paths: [SerialPort] = []
var serialService: io_object_t
repeat {
serialService = IOIteratorNext(portIterator)
if (serialService != 0) {
var serialPortInfo: SerialPort!
[
kIOCalloutDeviceKey,
kIOTTYDeviceKey,
kIOTTYBaseNameKey,
kIODialinDeviceKey
].forEach { (inspectKey) in
let currentKey: CFString = inspectKey as CFString
let valueCFString =
IORegistryEntryCreateCFProperty(
serialService,
currentKey,
kCFAllocatorDefault,
0
)
.takeUnretainedValue()
if let value = valueCFString as? String {
switch inspectKey {
case kIOCalloutDeviceKey:
serialPortInfo = .init(bsdPath: value)
case kIOTTYBaseNameKey:
serialPortInfo.ttyName = value
case kIOTTYDeviceKey:
serialPortInfo.ttyDevice = value
case kIODialinDeviceKey:
serialPortInfo.dialinPath = value
default:
break
}
}
}
if serialPortInfo != nil {
paths.append(serialPortInfo)
}
}
} while serialService != 0
return paths
}
Now, we can fetch all the serial ports available. Even more, by modifying the matchingDic
, we can look up any device type.
observing changes
The previous code works fine but does not display any changes in real-time. But what if we open a screen and connect a USB device (in my case - Arduino board) - nothing is happening - I either need to reopen the screen either add some button which on click will reload serial port data… Not the best approach.
To solve this we can use IOKit
notifications - IOServiceAddMatchingNotification
.
- create a notification
let adddedNotificationPort: IONotificationPortRef = IONotificationPortCreate(kIOMasterPortDefault)
// here optional step - we can specify queue on which we will process event
IONotificationPortSetDispatchQueue(adddedNotificationPort, processingIOKitQueue)
- add this notification as a source to
RunLoop
CFRunLoopAddSource(CFRunLoopGetCurrent(),
IONotificationPortGetRunLoopSource(
adddedNotificationPort).takeUnretainedValue(),
CFRunLoopMode.defaultMode)
If u would like to know a bit more about
RunLoop
- read my post
- use previously defined
matchingDic
for subscription to this event:
var matchingDict = IOServiceMatching(kIOSerialBSDServiceValue) as NSDictionary as! [String: AnyObject]
matchingDict[kIOSerialBSDTypeKey] = kIOSerialBSDAllTypes as AnyObject
let cfMatchingDictionary = matchingDict as CFDictionary
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
var portIterator: io_iterator_t = 0
let resultForPublish: kern_return_t = IOServiceAddMatchingNotification(
adddedNotificationPort,
kIOPublishNotification,
cfMatchingDictionary,
callbackForAddedPort,
selfPtr,
&portIterator
)
Again - resultForPublish
is kern_return_t
, as I described earlier. Also, u can see, that I used selfPtr
- we will use it a bit later, when we proceed notification. That’s why we should convert it to UnsafeMutableRawPointer
, and later convert it back:
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
// and back
let portDiscoverer = Unmanaged<SerialPortDiscoverer>.fromOpaque(refCon).takeUnretainedValue()
- define callback for notification
callbackForAddedPort
:
callbackForAddedPort
has a type defined as IOServiceMatchingCallback
:
public typealias IOServiceMatchingCallback = @convention(c) (UnsafeMutablePointer<Void>, io_iterator_t) -> Void
U can see @convention(c)
- this means, that by using this annotation we can refer to CFunctionPointer
- “The c argument is used to indicate a C function reference. The function value carries no context and uses the C calling convention.” (source)
So, we can use same syntax and define our callback as next:
let callbackForAddedPort: @convention(c) (UnsafeMutableRawPointer?, io_iterator_t) -> Void = { refCon, iterator in
if let refCon = refCon {
// reference to observer
let portDiscoverer = Unmanaged<SerialPortDiscoverer>.fromOpaque(refCon).takeUnretainedValue()
// here we will receive array with only 1 element
let newPorts = portDiscoverer.extractSerialPaths(portIterator: iterator)
newPorts.forEach(portDiscoverer.appendPort)
} else {
print("ref to observer obj not valid")
}
}
- clean up, when we done
To cleanUp, after we have done our job (on-screen close, for example), we should release ref to the pointer. In obj-c, we also should retain iterator and matchingDic
in our case, but swift manage part of the values for us. All we should do - is to release the pointer to iterator on failure or when we are done:
IOObjectRelease(portIterator)
We are ready to receive a notification.
But this is only the first part of the job, the second one - is to subscribe for kIOTerminatedNotification
- when some port was removed from the system (unplug from the USB). To do so, we should repeat all steps above, but replace the type of notification we would like to receive with kIOTerminatedNotification
instead of kIOPublishNotification
.
From the docs: A notification type from
IOKitKeys.h
kIOPublishNotification
Delivered when an IOService is registered.kIOFirstPublishNotification
Delivered when an IOService is registered, but only once per IOService instance. Some IOService’s may be reregistered when their state is changed.kIOMatchedNotification
Delivered when an IOService has had all matching drivers in the kernel probed and started.kIOFirstMatchNotification
Delivered when an IOService has had all matching drivers in the kernel probed and started, but only once per IOService instance. Some IOService’s may be reregistered when their state is changed.kIOTerminatedNotification
Delivered after an IOService has been terminated.
And don’t forget to iterate over the iterator, to receive all initial ports:
var serialService: io_object_t
repeat {
serialService = IOIteratorNext(iterator)
// do stuff here with service
} while serialService != 0
demo

Resources
IOKit
io_iterator_t
IOSerialKeys.h
- Technical Q&A QA1076 - Tips on USB driver matching for Mac OS X
- Serial Port Programming in Swift for MacOS
- USB Connection Delegate on Swift
- iterator
- SO New @convention(c) in Swift 2: How can I use it?
- Performing Serial I/O
- Detect Serial Devices in Swift
- macOS USB Enumeration in C
- Communicating with a Modem on a Serial Port
- Hello IOKit: Creating a Device Driver With Project Builder
- ORSSerialPort
Share on: