Can I combine an LED and a button on the ESP32C6 with Swift?

This article is part of a series.

So I wasn’t satisfied with just adding a switch to the existing demo… I had to start making it fancier.

For some reason right about when I switched to writing my own Swift code my esp-idf install stopped working? It took me awhile to realize to was the tooling, not my code. The idf was handing out error messages about the creation of project_description.json on ALL projects and idf.py menuconfig crashed hard in multi-colored glory when called, even on examples that worked previously.

I reinstalled the whole esp-idf again and everything worked fine. I’m not sure if it was a software update overnight on my computer or if my spelunking around in that folder did something. Thank goodness for high speed internet because that was another big download.

After all that I ended up with two more steps of the project done:

Compare to:

Project Anatomy

I’m going to focus on recapping the project with both the LED and the button working, which has the following files:

|- main
|  |- Bool_Int.swift
|  |- BridgingHeader.swift
|  |- CMakeLists.txt
|  |- Delay.swift
|  |- DigitalIndicator.swift
|  |- MomentaryInput.swift
|  |- GPIOPin.swift
|  |- Main.swift
|  |- idf_component.yml
|
|- CMakeLists.txt
|- .gitignore

All of these files were added by hand to main/CMakeLists.txt

# List of Swift source files to build.
target_sources(${COMPONENT_LIB}
    PRIVATE
    Main.swift
    Bool_Int.swift
    GPIOPin.swift
    DigitalIndicator.swift
    MomentaryInput.swift
    Delay.swift
)

Main.swift

Notice that the only call directly backed by the esp32C6 sdk is print, many SDK’s implement print but it isn’t guaranteed. ESP_LOG and print do not behave identically. That’s its own post! What we do have is a DigitalIndicator type and a MomentaryInput type. I think it’s possible those would be better as protocols (DigitallyIndicating and ``DigitalReceiving`?), but for now they’re concrete types.

@_cdecl("app_main")
func main() {
  print("Hello from Swift on ESP32-C6!")

  guard var led = DigitalIndicator(15) else {
    fatalError("Difficulty setting up pin.")
  }

  guard let button = MomentaryInput(9) else {
    fatalError("Difficulty setting up button.")
  }

  while true {
    if button.isActive {
      led.blink(millis: 500)
    } else {
      led.blink(millis: 2000)
    }
  }
}

DigitalIndicator.swift

Also NO esp32c6 specific code

A DigitalIndicator has some more things defined

struct DigitalIndicator {
    let pin:OutputPin 
    let activeLevel: GPIOLevel
    var expectedState: Bool

    func set(on:Bool) {
        let level = on ? activeLevel.asBool : !activeLevel.asBool
        pin.setLevel(levelHigh:level)
    }

    mutating func turnOn() {
        self.set(on: true)
        expectedState = true
    }

        mutating func turnOff() {
        self.set(on: false)
        expectedState = false
    }

    mutating func toggle() {
        let level: Bool = expectedState ? false : true
        self.set(on: level)
        expectedState = level
    }

    mutating func blink(millis:UInt32) {
        self.toggle()  // Toggle the boolean value
        delay(millis)
    }

    mutating func blink(onMillis:UInt32, offMillis:UInt32) {
        self.turnOn()
        delay(onMillis)
        self.turnOff()
        delay(offMillis)
    }
}

extension DigitalIndicator {
    init?(_ pinNum: UInt32, activeLow:Bool = true) {
        self.pin = OutputPin(pinNumber: pinNum, activeLow:activeLow)
        self.activeLevel = GPIOLevel(!activeLow)
        self.expectedState = false
    }
}

Delay.swift

A ESP32C6 specific implementation of a delay function. TODO: why did the demo code use configTICK_RATE_HZ instead of portTICK_PERIOD_MS?

func delay(_ millis:UInt32) {
    vTaskDelay(millis / (1000 / UInt32(configTICK_RATE_HZ)))
}

GPIOPin.swift:GPIOLevel and Bool_Int.swift

No esp32c6 specific code. But maybe it could. Considering creating a typealias for archInt and archUInt.

These two work to clarify that 1, true and .high all mean the same thing, and to translate C’s 1/0 returns to true/false easily as needed.

//TODO: raw value? Bool or UInt32? 
enum GPIOLevel:Equatable {
    case high
    case low
}

extension GPIOLevel {
    init(_ v:Int32) {
        if v == 0 {
            self = .low
        } else if v == 1 {
            self = .high
        } else {
            fatalError("Expected 0 or 1, got \(v)")
        }
    }

    init(_ v:UInt32) {
        if v == 0 {
            self = .low
        } else if v == 1 {
            self = .high
        } else {
            fatalError("Expected 0 or 1, got \(v)")
        }
    }

    init(_ v:Bool) {
        if v == false {
            self = .low
        } else {
            self = .high
        }
    }

    var asUInt32:UInt32 {
        (self == .high) ? 1 : 0
    }

    var asInt32:Int32 {
        (self == .high) ? 1 : 0
    }

    var asBool:Bool {
        (self == .high) ? true : false
    }
}
extension Bool {
    init(_ v:UInt32) {
        if v == 0 {
            self = false
        } else if v == 1 {
            self = true
        } else {
            fatalError("Expected 0 or 1, got \(v)")
        }
    }

    init(_ v:Int32) {
        if v == 0 {
            self = false
        } else if v == 1 {
            self = true
        } else {
            fatalError("Expected 0 or 1, got \(v)")
        }
    }

    var asUInt32:UInt32 {
        self ? 1 : 0
    }

}

GPIOPin.swift:GPIODirection

This has a esp32C6 dynamic var, could be an extension in sub package.

enum GPIODirection  {
    case input
    case output
}

//esp32C6 implementation
extension GPIODirection {
    var esp32C6:gpio_mode_t {
        switch(self) {
            case .input: 
                return GPIO_MODE_INPUT
            case .output: 
                return GPIO_MODE_OUTPUT
        }
    }
}

GPIOPin.swift:GPIOPin

GPIOPin is a protocol

protocol GPIOPin {
    var pinNumber:UInt32 { get }
    var direction: GPIODirection { get }
}

GPIOPin.swift:OutputPin

I decided to make OutputPin a Struct (esp32c6 specific) rather than a Protocol for now because I didn’t want to deal with something like DigitalIndicator<ESP32C6OutputPin>(15) for now. This could also be handled by typaliasing, but I have some questions around how to best use Swift embedded with existentials and generics that I’d like to temporarily side step.

This being a struct is a tad deceiving, It’s backed by C functions that point to specific hardware registers. In a Swift-all-the-way-down implementation I would maybe choose a final Class or even an Actor if that becomes a thing one could do?

//MARK: OutputPin
struct OutputPin:GPIOPin {
    let pinNumber:UInt32
    let direction:GPIODirection = .output

    func setLevel(levelHigh:Bool) {
        gpio_set_level(gpio_num_t(Int32(pinNumber)), levelHigh.asUInt32)
    }

    func setLevel(level:GPIOLevel) {
        gpio_set_level(gpio_num_t(Int32(pinNumber)), level.asUInt32)
    }
}


//esp32C6 init
extension OutputPin {
    init(pinNumber:UInt32, activeLow:Bool = true) {
        //validate is GPIO output pin.
        let ledPin = gpio_num_t(Int32(pinNumber))
        self.pinNumber = pinNumber

        guard gpio_reset_pin(ledPin) == ESP_OK else {
            fatalError("cannot reset output pin \(pinNumber)")
        }
        guard gpio_set_direction(ledPin, self.direction.esp32C6) == ESP_OK else {
            fatalError("cannot reset output pin \(pinNumber)")
        }
    }
}

GPIOPin.swift:InputPin

ESP32C6 specific implementation, see notes above with OutputPin

struct InputPin:GPIOPin {
    let pinNumber:UInt32
    let direction:GPIODirection = .input

    //If the pad is not configured for input (or input and output) the returned value is always 0.
    func readLevel() -> GPIOLevel {
        GPIOLevel(gpio_get_level(gpio_num_t(Int32(pinNumber))))
    }
}

extension InputPin {
    init(pinNumber:UInt32, activeLow:Bool = true, useInternalHardware:Bool = true) {
        //validate is GPIO output pin.
        let ledPin = gpio_num_t(Int32(pinNumber))
        self.pinNumber = pinNumber

        guard gpio_reset_pin(ledPin) == ESP_OK else {
            fatalError("cannot reset output pin \(pinNumber)")
        }

        guard gpio_set_direction(ledPin, self.direction.esp32C6) == ESP_OK else {
            fatalError("cannot reset output pin \(pinNumber)")
        }

        //validate pin has hardware 
        if activeLow && useInternalHardware {
            //use gpio_pull_mode_t
            gpio_set_pull_mode(ledPin, GPIO_PULLUP_ONLY)
        } else if !activeLow && useInternalHardware {
            gpio_set_pull_mode(ledPin, GPIO_PULLDOWN_ONLY)
        } else {
            //could set with GPIO_FLOATING, but for now want to leave it as default
        }
    }
}

Summary

All of this was pretty easy once I got the the tooling issues sorted. The next batch, getting the WiFi working will require adding a C bridging component. I tried without but the WiFi library uses function like macros, and while there maybe a fancy work around, i’m just going to add a lib.

This article is part of a series.