Can I open a serial port with Swift? Part II
This article is part of a series.
Another brain dump, building on the last post
Download 3 project code zip (741kb)
Relevant Repos:
- https://github.com/carlynorama/swiftserialport
- https://github.com/carlynorama/SerialSession
- https://github.com/carlynorama/SerialSessionUI
Shocking no one, I did not focus on the higher level API and did in fact write my own SwiftSerialPort library.
Lucky for me, I can pretty easily get my hands on the very same @todbot (<3) whose arduino-serial library was referenced in so many of the links provided by yeokm1.
SwiftSerial served very well as a place to start. I’m just targeting such a different application that a fork wasn’t allowing me to do the reorg I wanted.
Some of the differences in SwiftSerialPort
:
- A
SerialPort
is a class instead of a struct. It must be able to create a file descriptor in order toinit
. It closes ondeinit
. - I pulled much of the C into it’s own section of the Package. I can then import it (and not Foundation) into the Swift as needed.
- I made a CLI target for testing things as I go. It’s called
cereal
. - Different enums for different types of Errors
- I rely much more on the
termios
struct’s default settings. I’m not really going to do much that doesn’t have an 8N1 setting, for example. It works. I’m not going to poke at it. - On a related note, I didn’t enjoy using an enum for the baud rates. I can now just type what I want and have a C function validate it. That was a todism. (see validate_baudrate as the derivative)
- The write function takes a generic input parameter, while also taking advantage of the 5.7 introduced unsafe closure syntax
withUnsafeByes {}
.
public func write<T>(_ outBytes:T) throws -> Int {
return try withUnsafeBytes(of: outBytes) { message in
let r = write_to_port(fileDescriptor, message.baseAddress, message.count)
if r == -1 {
throw SerialCommunicationError.couldNotWrite
}
return r
}
}
- For my line reader I do a batch read of everything available and then parse it out in Swift. Much more efficient, but it still needs a little work on handling mid-line breaks.
public func readAvailableLines(maxSplits:Int = 10000) throws -> (lines:[String], remainder:String) {
var buffer = try readAllAvailable()
buffer.append(0) //cString must be null terminated
var dataAsString = String(cString: buffer)
var remainder:String
if let lastNewLine = dataAsString.lastIndex(where: { $0.isNewline }) {
remainder = String(dataAsString[lastNewLine...].trimmingPrefix(while: { $0.isNewline }))
dataAsString.removeSubrange(lastNewLine...)
} else {
remainder = ""
}
let lines = dataAsString.split(maxSplits: maxSplits,
omittingEmptySubsequences: true,
whereSeparator: { $0.isNewline })
.map { String($0) }
print(lines)
return (lines, remainder)
}
- I did write and async function that can accommodate a blocking read. I also made it possible to specify read timeout/escape early parameters by setting VTIME and VMIN for the port.
//updates port configuration to time out of waiting, if desired.
//Adding a byte min means code will not return until it has AT LEAST that many bytes.
try serialPort.setReadEscapes(wait:30, minNumberOfBytes:0)
//make the async call.
async let incomingMessage3 = serialPort.awaitBytes(count: 20)
//proof code can go do other things.
print("some other activities...")
//depending on whats on the Ardunio and/or what I put in setReadEscapes
//could be almost instant, could be never.
print("awaited: \(await incomingMessage3)")
//wait is in tenths of seconds
//minNumberOfBytes should be lower than the bytes the read is requesting.
//see http://unixwiz.net/techtips/termios-vmin-vtime.html
public func setReadEscapes(wait vtime:UInt8, minNumberOfBytes vmin:UInt8) throws {
let errors = set_early_fail_behavior(fileDescriptor, vtime, vmin)
if errors < 0 {
throw SerialSettingsError.settingNotUpdated
}
}
//assumes the following style opening.
// fd = open("/dev/ttyS0", O_RDWR | O_NOCTTY | O_NDELAY);
// fcntl(fd, F_SETFL, 0);
int set_early_fail_behavior(const int file_descriptor, const cc_t new_vtime, const cc_t new_vmin) {
/* get the current options */
int r;
struct termios settings;
tcgetattr(file_descriptor, &settings);
if (r < 0) {
perror("update_baudrate: couldn't get current settings");
return -1;
}
settings.c_cc[VTIME] = new_vtime;//10;
settings.c_cc[VMIN] = new_vmin; //0
/* set the options */
tcsetattr(file_descriptor, TCSANOW, &settings);
if (r < 0) {
perror("update_baudrate: couldn't set VTIME and VMIN");
return -2;
}
return 0;
}
public func awaitBytes(count:Int) async -> Result<[UInt8], Error> {
var dataBuffer = Array<UInt8>(repeating: 0, count: count)
let bytesReceived = dataBuffer.withUnsafeMutableBufferPointer { bufferPointer in
return default_read_from_port(fileDescriptor, bufferPointer.baseAddress, count)
}
if bytesReceived == 0 {
//Depends on port settings if this is possible
return .failure(SerialCommunicationError.noBytesReceived)
} else if bytesReceived < 0 {
return .failure(SerialCommunicationError.couldNotRead)
}
return .success(Array(dataBuffer.prefix(bytesReceived)))
}
//Uses port settings.
size_t default_read_from_port(const int file_descriptor, void * buffer, const size_t count) {
int result = read(file_descriptor, buffer, count);
return result;
}
What else is new
The two original repos got a face lift, swapping in SwiftSerialPort library.
SerialSessionUI, the MacOS/SwiftUI project, has 3 views now
View A | View B | View C |
---|---|---|
A write view with a slider | A read view that batch dumps bytes into the screen after casting the data as a String (if possible). | A view that breaks transmitted ASCII text (new lines separated) into an array of Strings. |
import SwiftUI
import SerialSession
//Assumes code on board is ForWriteView.ino
struct WriteView: View {
@EnvironmentObject var serialWriter:SerialSession
@State var brightness:Double = 0.5
@State var toTransmit:UInt8 = UInt8(0.5 * 255)
@State var sent = "–"
var body: some View {
VStack(alignment: .leading) {
Text("slider:\(brightness), toSend:\(toTransmit) (\(sent))").monospaced()
Slider(value: $brightness) { editing in
if !editing {
toTransmit = UInt8(brightness * 255)
let result = serialWriter.send(toTransmit)
switch result {
case .success:
sent = "√"
case .failure(let error):
sent = "X"
print(error)
}
}
}
}
.padding()
}
}
import SwiftUI
import SerialSession
struct LineBatchView: View {
@EnvironmentObject var serialReader:SerialSession
@State var availableLines:[String] = Array(repeating: "No data yet", count: 1)
@State var remainder = ""
var body: some View {
VStack {
Button("Read") {
updateLines()
}
ScrollView {
ForEach(Array(availableLines.enumerated()), id: \.offset) {
Text("\($0.offset): \($0.element)").monospaced()
}
}
}
}
func updateLines() {
//let result = serialReader.readLine()
let result = serialReader.readLines()
switch result {
case .success(let data):
var dataIterator = data.lines.makeIterator()
while let line = dataIterator.next() {
print(line, terminator: ", ")
}
print()
//Maybe?
availableLines = data.lines
availableLines[0] = remainder + availableLines[0]
remainder = data.remainder
case .failure(let error):
availableLines = Array(repeating: "No data available", count: 1)
remainder = ""
print(error)
}
}
}
import SwiftUI
import SerialSession
//Assumes code on board is ForReadBytesView.ino
struct ReadAvailableBytesView: View {
@EnvironmentObject var serialReader:SerialSession
@State var reading:String = "Press button to retrieve available data"
var body: some View {
VStack {
Text("\(reading)")
Button("Read") {
updateText()
}
}
.padding()
}
func updateText() {
//let result = serialReader.readLine()
let result = serialReader.readAvailable()
switch result {
case .success(let message):
var dataIterator = message.makeIterator()
while let animal = dataIterator.next() {
print(animal, terminator: ", ")
}
print()
reading = String(data: message, encoding: .utf8) ?? "failure decoding."
case .failure(let error):
reading = "(no data available)"
print(error)
}
}
}
The Arduino code for View A and View C were provided in the last post.
The code for View B:
/*
Prints bytes that happen to be valid ascii characters to the serial
port in a repeating loop.
The circuit:
- None to speak of
created 2023
by Carlyn Maw
This example code is in the public domain.
*/
void setup() {
// initialize the serial communication:
Serial.begin(9600);
}
int thisByte = 33;
void loop() {
Serial.write(thisByte);
thisByte ++;
if (thisByte > 126) {
thisByte = 33;
}
//give the serial port a break.
//can play with number to check on effectiveness of VTIME settings
delay(2);
}
Serial Session
Even though I have control of the underlying library now, I still left the serial-port-as-service model set up. It will allow for creating a mock service for testing, or swapping out a port connection based on I/OKit/DriverKit, etc. Maybe even using it to wrap BlueTooth. It’s still a good pattern.
public protocol SerialPortService {
static func make(devicePath:String, with:SerialPortConfiguration?) throws -> Self
//If a real service, must handle closing and releasing the fileDescriptor
//on destruction.
//func close() -> Void
func write<T>(_ outBytes:T) throws -> Int
func bytesAvailable() -> Int
//Never block, only takes whats available
//Must be careful to not call these too too frequently
func readIfAvailable(maxCount:Int) throws -> [UInt8]
func readAllAvailable() throws -> [UInt8]
func readAvailableLines(maxLength:Int) throws -> (lines:[String], remainder:String)
//TODO: make async instead. Currently uses non blocking read.
func readUntil(oneOf:[UInt8], orMaxCount maxCount:Int, tries:Int) -> [UInt8]
//Uses readEscape configuration. If that's not set, will never return.
func awaitBytes(count:Int) async -> Result<[UInt8], Error>
}
The make() function is pretty straight forward and is in an extension on SwiftSerialPort in the library that needs it. I’ve heard that factory methods aren’t “Swifty”, but I think they work really well to not gunkup the original type when conforming to a protocol. A better name for it would be makeForSession
. Either way takes in the now much shorter configuration type.
public struct SerialPortConfiguration {
let baudRate: CInt?
let readEscape: (timeOut:UInt8, byteMin:UInt8)?
}
I temporarily removed the convenience wrapper on the functions because it was clunky. It’s might be a job for a property wrapper or macro? TBD.
//
// SerialSession.swift
// SerialSession
//
// Created by Carlyn Maw on 8/24/23.
//
//TODO: Replace Error types?
import SwiftSerialPort
import Foundation //For Data type
public typealias SerialSession = BaseSerialSession<SerialPort>
public class BaseSerialSession<Port:SerialPortService> {
let devicePath:String
var serialPort:Port?
var configuration:SerialPortConfiguration?
var isOpen:Bool {
serialPort != nil
}
init(devicePath:String,
serialPort:Port?,
settings:SerialPortConfiguration?) {
self.devicePath = devicePath
self.configuration = settings
self.serialPort = serialPort
self.configuration = settings
}
deinit {
close()
}
func open(forcedReset:Bool = false) throws {
print("Attempting to open port: \(devicePath)")
if forcedReset || !isOpen {
serialPort = nil
self.serialPort = try Port.make(devicePath: devicePath, with: configuration)
}
}
func close() {
serialPort = nil //
print("Port Closed")
}
}
//MARK: SerialSession inits
extension BaseSerialSession where Port == SwiftSerialPort.SerialPort {
public convenience init(devicePath: String, settings:SerialPortConfiguration? = nil) {
var port:Port? = nil
do {
port = try SerialPort.make(devicePath: devicePath, with: settings)
} catch {
print("Creating session with nil port: \(error)")
}
self.init(devicePath: devicePath, serialPort: port, settings: settings)
}
convenience init(devicePath: String,
baudRate:CInt) {
let settings = SerialPortConfiguration(baudRate: baudRate, readEscape: nil)
self.init(devicePath:devicePath, settings:settings)
}
}
//TODO: re-make wrapped?
extension BaseSerialSession {
//MARK: Transmit
@discardableResult
public func pingII() -> Result<Int, Error> {
if let serialPort {
do {
let bytesWritten = try serialPort.write("A")
return .success(bytesWritten)
} catch {
return .failure(error)
}
} else {
return .failure(SerialSessionError.noActivePort)
}
}
@discardableResult
public func send<T>(_ value:T) -> Result<Int, Error> {
if let serialPort {
do {
let bytesWritten = try serialPort.write(value)
return .success(bytesWritten)
} catch {
return .failure(error)
}
} else {
return .failure(SerialSessionError.noActivePort)
}
}
//MARK: Receive
public func readAvailable() -> Result<Data, Error>{
if let serialPort {
do {
let bytesReceived = try serialPort.readAllAvailable()
if bytesReceived.isEmpty {
return .failure(SerialSessionError.noBytesAvailable)
}
return .success(Data(bytesReceived))
} catch {
return .failure(error)
}
} else {
return .failure(SerialSessionError.noActivePort)
}
}
//TODO: Have Session handle remainders as part of AsyncStream iterator.
public func readLines() -> Result<(lines:[String], remainder:String), Error>{
if let serialPort {
do {
let dataReceived = try serialPort.readAvailableLines(maxLength: 1000)
if dataReceived.lines.isEmpty {
return .failure(SerialSessionError.noBytesAvailable)
}
return .success(dataReceived)
} catch {
return .failure(error)
}
} else {
return .failure(SerialSessionError.noActivePort)
}
}
}
Another reference dump
Several duplicates from last post.
- On your machine, find and open termios.h (macOS spotlight finds it fine.)
- https://www.msweet.org/serial/serial.html
Other Serial Libraries
- https://github.com/todbot/arduino-serial/
- https://swiftpackageindex.com/yeokm1/SwiftSerial
- Author’s talk: https://www.youtube.com/watch?v=6PWP1eZo53s
- https://doc.qt.io/qt-6/qserialport.html
Swift/Apple links
- https://developer.apple.com/documentation/iokit
- https://developer.apple.com/documentation/iokit/communicating_with_a_modem_on_a_serial_port
- https://developer.apple.com/documentation/driverkit
- if decide to use libusb: https://forums.swift.org/t/linking-to-c-libraries/55651/2
- https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/tcsetattr.3.html
C/terminos links.
- https://tldp.org/HOWTO/Serial-Programming-HOWTO/intro.html
- https://en.wikibooks.org/wiki/Serial_Programming/termios
- https://man7.org/linux/man-pages/man3/termios.3.html
- https://blog.nelhage.com/2009/12/a-brief-introduction-to-termios/
- http://unixwiz.net/techtips/termios-vmin-vtime.html
- talks about open()/fileDescriptors etc: https://www.youtube.com/watch?v=BQJBe4IbsvQ
- https://en.wikibooks.org/wiki/Serial_Programming/termios
- https://www.gnu.org/software/libc/manual/html_node/Setting-Modes.html
- https://stackoverflow.com/questions/31999358/how-are-flags-represented-in-the-termios-library
- https://circuitdigest.com/tutorial/serial-communication-protocols
- https://www.wevolver.com/article/baud-rates-the-most-common-baud-rates
- http://unixwiz.net/techtips/termios-vmin-vtime.html
- https://www.xanthium.in/Serial-Port-Programming-on-Linux
Arduino
- https://www.arduino.cc/reference/en/language/functions/communication/serial/begin/
- https://chrisheydrick.com/2012/06/17/how-to-read-serial-data-from-an-arduino-in-linux-with-c-part-3/