Recently 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.

preview



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.

  1. create a notification
let adddedNotificationPort: IONotificationPortRef = IONotificationPortCreate(kIOMasterPortDefault)
// here optional step - we can specify queue on which we will process event
IONotificationPortSetDispatchQueue(adddedNotificationPort, processingIOKitQueue)
  1. 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

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

demo_serialPorts



Resources