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:

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:

    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
        }
    }
    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)
    }
//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
screen shot screen shot screen shot
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.

Other Serial Libraries

Arduino

This article is part of a series.